diff --git a/.circleci/config.yml b/.circleci/config.yml index 6be1e7d59..1705a9190 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -430,6 +430,66 @@ jobs: - slack/notify-on-failure: only_for_branches: master,production + deploy-shared: + parameters: + serviceName: + type: string + executor: python-executor + working_directory: ~/back + steps: + # Attach workspace from build + - attach_workspace: + at: ~/ + # install gcloud orb + - gcp-cli/install + - run: + name: Create version.txt + command: | + echo "CircleCI build number:${CIRCLE_BUILD_NUM} + Branch: ${CIRCLE_BRANCH} + Git hash: ${CIRCLE_SHA1}" > version.txt + - run: + # GAE only installs dependencies from requirements.txt + name: Extend dependencies to be installed in deploy-environment + command: cat requirements-deploy.txt >> requirements.txt + - run: + name: Generate .env file + command: | + echo " + MYSQL_USER=${DB_SELECTONLY_USER} + MYSQL_PASSWORD=${DB_SELECTONLY_PASS} + MYSQL_DB=${DB_NAME} + MYSQL_SOCKET=${DB_SOCKET} + MYSQL_REPLICA_SOCKET=${DB_REPLICA_SOCKET} + SENTRY_DSN=${SENTRY_BE_DSN} + SENTRY_ENVIRONMENT=${ENVIRONMENT} + SENTRY_RELEASE=${CIRCLE_SHA1} + SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE} + SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE} + ENVIRONMENT=${ENVIRONMENT} + " > .env + - run: + name: Generate GAE app.yaml file + command: | + sed 's/service: api-staging/service: << parameters.serviceName >>/g' app-shared.yaml > app-<< parameters.serviceName >>.yaml + - run: + name: Compress deployment artificats + command: tar -cvzf ~/deploy.tar.gz ~/back + - store_artifacts: + name: Store copy of deployment artifacts + path: ~/deploy.tar.gz + - run: + name: Authenticate the gcloud orb + command: | + echo $GCLOUD_SERVICE_KEY | gcloud auth activate-service-account --key-file=- + gcloud --quiet config set project ${GOOGLE_PROJECT_ID} + - run: + name: Deploy to GAE + command: | + gcloud app deploy app-<< parameters.serviceName >>.yaml --version ${CIRCLE_SHA1} + - slack/notify-on-failure: + only_for_branches: master,production + workflows: test: jobs: @@ -502,6 +562,16 @@ workflows: branches: only: - master + - deploy-shared: + name: deploy-shared-staging + context: STAGING + serviceName: api-staging + requires: + - checkout-to-workspace + filters: + branches: + only: + - master # demo - runs on any commit to the production branch - build-front: diff --git a/back/README.md b/back/README.md index d1ac1ffdf..3e9add5c0 100755 --- a/back/README.md +++ b/back/README.md @@ -341,13 +341,19 @@ The following diagram shows the responsibilities of and the relationships betwee ## GraphQL API -The back-end exposes the GraphQL API in two variants. +The back-end exposes the GraphQL API in three variants. 1. The auth-protected, full API is consumed by our front-end at the `/graphql` endpoint (deployed to e.g. `v2-staging` subdomain). 1. The auth-protected, 'query-only' API is used by our partners at `/` (for data retrieval; it is deployed on the `api*` subdomains). -1. The public statistics API is used by our partners at `/public` (for data retrieval; it is deployed on the `api*` subdomains). +1. The public BE for resolving shared links will be available at `/public` (for data retrieval; it is deployed on the `shared-*` subdomains). -Starting the back-end in the first case is achieved via `main.py`, in the latter case via `api_main.py`. For development, it is handy to start both with `dev_main.py`. +Starting the back-end depends on the variant: + +1. via `main.py` +1. via `api_main.py` +1. via `shared_main.py` + +For development, it is handy to start all with `dev_main.py`. ### Schema documentation @@ -361,7 +367,7 @@ You can experiment with the API in the `GraphiQL` GraphQL explorer. 1. Set up the `.env` file and acquire the test client secret as described [here](#test-environment-set-up). 1. Start the required services by `docker compose up webapp` -1. Open `localhost:5005/graphql` (or `/` for the query-only API; or `/public` for the statviz API, then the next steps can be skipped) +1. Open `localhost:5005/graphql` (or `/` for the query-only API) 1. Simulate being a valid, logged-in user by fetching an authorization token: `docker compose exec webapp ./back/fetch_token --test` 1. Copy the displayed token 1. Insert the access token in the following format in the section called 'Headers' on the bottom left of the explorer. diff --git a/back/app-shared.yaml b/back/app-shared.yaml new file mode 100644 index 000000000..73e7a5e18 --- /dev/null +++ b/back/app-shared.yaml @@ -0,0 +1,7 @@ +runtime: python312 +service: api-staging +entrypoint: gunicorn -b :$PORT boxtribute_server.shared_main:app +handlers: +- url: /public + script: auto + secure: always diff --git a/back/boxtribute_server/blueprints.py b/back/boxtribute_server/blueprints.py index 3d3a20435..663cb5d35 100644 --- a/back/boxtribute_server/blueprints.py +++ b/back/boxtribute_server/blueprints.py @@ -6,6 +6,10 @@ # Blueprint for app GraphQL server. Deployed on v2-* subdomains app_bp = Blueprint("app_bp", __name__) +# Blueprint for public BE to resolve shared links. Deployed on shared-* subdomains +shared_bp = Blueprint("shared_bp", __name__) + API_GRAPHQL_PATH = "/" APP_GRAPHQL_PATH = "/graphql" +SHARED_GRAPHQL_PATH = "/public" CRON_PATH = "/cron" diff --git a/back/boxtribute_server/business_logic/statistics/crud.py b/back/boxtribute_server/business_logic/statistics/crud.py index f77e547a7..25a38feca 100644 --- a/back/boxtribute_server/business_logic/statistics/crud.py +++ b/back/boxtribute_server/business_logic/statistics/crud.py @@ -1,8 +1,10 @@ import hashlib import random import string +from dataclasses import dataclass from datetime import timedelta from functools import wraps +from typing import Any from peewee import JOIN, SQL, fn @@ -28,6 +30,13 @@ from .sql import MOVED_BOXES_QUERY +@dataclass(kw_only=True) +class DataCube: + facts: list[dict[str, Any]] + dimensions: list[dict[str, Any]] + type: str # Identical to GraphQL DataCube implementation type + + def use_db_replica(f): """Decorator for a resolver that should use the DB replica for database selects.""" @@ -158,7 +167,9 @@ def compute_beneficiary_demographics(base_id): ) dimensions = _generate_dimensions("tag", facts=demographics) - return {"facts": demographics, "dimensions": dimensions} + return DataCube( + facts=demographics, dimensions=dimensions, type="BeneficiaryDemographicsData" + ) def compute_created_boxes(base_id): @@ -272,7 +283,7 @@ def oldest_rows(field): ).dicts() dimensions = _generate_dimensions("category", "product", "tag", facts=facts) - return {"facts": facts, "dimensions": dimensions} + return DataCube(facts=facts, dimensions=dimensions, type="CreatedBoxesData") def compute_top_products_checked_out(base_id): @@ -297,7 +308,9 @@ def compute_top_products_checked_out(base_id): dimensions = _generate_dimensions("category", "product", facts=facts) dimensions["size"] = None - return {"facts": facts, "dimensions": dimensions} + return DataCube( + facts=facts, dimensions=dimensions, type="TopProductsCheckedOutData" + ) def compute_top_products_donated(base_id): @@ -339,7 +352,7 @@ def compute_top_products_donated(base_id): ).dicts() dimensions = _generate_dimensions("category", "product", "size", facts=facts) - return {"facts": facts, "dimensions": dimensions} + return DataCube(facts=facts, dimensions=dimensions, type="TopProductsDonatedData") def compute_moved_boxes(base_id): @@ -398,7 +411,7 @@ def compute_moved_boxes(base_id): facts=facts, )["target"] ) - return {"facts": facts, "dimensions": dimensions} + return DataCube(facts=facts, dimensions=dimensions, type="MovedBoxesData") def compute_stock_overview(base_id): @@ -480,7 +493,7 @@ def compute_stock_overview(base_id): dimensions = _generate_dimensions( "size", "location", "category", "tag", "dimension", facts=facts ) - return {"facts": facts, "dimensions": dimensions} + return DataCube(facts=facts, dimensions=dimensions, type="StockOverviewData") def create_shareable_link( diff --git a/back/boxtribute_server/business_logic/statistics/fields.py b/back/boxtribute_server/business_logic/statistics/fields.py new file mode 100644 index 000000000..0e709ffb2 --- /dev/null +++ b/back/boxtribute_server/business_logic/statistics/fields.py @@ -0,0 +1,20 @@ +from ariadne import ObjectType + +from .crud import ( + compute_beneficiary_demographics, + compute_created_boxes, + compute_moved_boxes, + compute_stock_overview, +) + +resolved_link = ObjectType("ResolvedLink") + + +@resolved_link.field("data") +def resolve_resolved_link_data(resolved_link_obj, _): + return [ + compute_beneficiary_demographics(resolved_link_obj.base_id), + compute_created_boxes(resolved_link_obj.base_id), + compute_moved_boxes(resolved_link_obj.base_id), + compute_stock_overview(resolved_link_obj.base_id), + ] diff --git a/back/boxtribute_server/business_logic/statistics/queries.py b/back/boxtribute_server/business_logic/statistics/queries.py index 8b0fee624..0008d3691 100644 --- a/back/boxtribute_server/business_logic/statistics/queries.py +++ b/back/boxtribute_server/business_logic/statistics/queries.py @@ -1,6 +1,11 @@ +from datetime import timezone + from ariadne import QueryType from ...authz import authorize, authorize_cross_organisation_access +from ...errors import ExpiredLink, UnknownLink +from ...models.definitions.shareable_link import ShareableLink +from ...models.utils import utcnow from . import query from .crud import ( compute_beneficiary_demographics, @@ -91,37 +96,20 @@ def resolve_stock_overview(*_, base_id): return compute_stock_overview(base_id) -@public_query.field("beneficiaryDemographics") -@use_db_replica -def public_resolve_beneficiary_demographics(*_, base_id): - return compute_beneficiary_demographics(base_id) - - -@public_query.field("createdBoxes") +@public_query.field("resolveLink") @use_db_replica -def public_resolve_created_boxes(*_, base_id): - return compute_created_boxes(base_id) +def resolve_shareable_link(*_, code): + # Enable resolving union by masking actual model class with subclass whose name + # matches the required GraphQL type + class ResolvedLink(ShareableLink): + class Meta: + table_name = ShareableLink._meta.table_name + link = ResolvedLink.get_or_none(ResolvedLink.code == code) + if link is None: + return UnknownLink(code=code) -@public_query.field("topProductsCheckedOut") -@use_db_replica -def public_resolve_top_products_checked_out(*_, base_id): - return compute_top_products_checked_out(base_id) + if link.valid_until.replace(tzinfo=timezone.utc) < utcnow(): + return ExpiredLink(valid_until=link.valid_until) - -@public_query.field("topProductsDonated") -@use_db_replica -def public_resolve_top_products_donated(*_, base_id): - return compute_top_products_donated(base_id) - - -@public_query.field("movedBoxes") -@use_db_replica -def public_resolve_moved_boxes(*_, base_id=None): - return compute_moved_boxes(base_id) - - -@public_query.field("stockOverview") -@use_db_replica -def public_resolve_stock_overview(*_, base_id): - return compute_stock_overview(base_id) + return link diff --git a/back/boxtribute_server/dev_main.py b/back/boxtribute_server/dev_main.py index 9f84ad046..506384cfc 100644 --- a/back/boxtribute_server/dev_main.py +++ b/back/boxtribute_server/dev_main.py @@ -1,9 +1,9 @@ """Development main entry point for webapp back-end AND query API""" from .app import main -from .routes import api_bp, app_bp +from .routes import api_bp, app_bp, shared_bp -app = main(api_bp, app_bp) +app = main(api_bp, app_bp, shared_bp) def run(): diff --git a/back/boxtribute_server/errors.py b/back/boxtribute_server/errors.py index 1def6f8fa..1d75a548f 100644 --- a/back/boxtribute_server/errors.py +++ b/back/boxtribute_server/errors.py @@ -77,3 +77,13 @@ def __init__(self, *, name): class DeletedLocation(UserError): def __init__(self, *, name): self.name = name + + +class ExpiredLink(UserError): + def __init__(self, *, valid_until): + self.valid_until = valid_until + + +class UnknownLink(UserError): + def __init__(self, *, code): + self.code = code diff --git a/back/boxtribute_server/graph_ql/bindables.py b/back/boxtribute_server/graph_ql/bindables.py index 98279d354..3f72c1a7e 100644 --- a/back/boxtribute_server/graph_ql/bindables.py +++ b/back/boxtribute_server/graph_ql/bindables.py @@ -63,7 +63,9 @@ from ..business_logic.mobile_distribution.tracking_group.queries import ( query as distribution_events_tracking_group_query, ) +from ..business_logic.statistics.fields import resolved_link from ..business_logic.statistics.mutations import mutation as statistics_mutation +from ..business_logic.statistics.queries import public_query as shareable_link_query from ..business_logic.statistics.queries import query as statistics_query from ..business_logic.tag.fields import tag from ..business_logic.tag.mutations import mutation as tag_mutation @@ -171,6 +173,10 @@ def resolve_location_type(obj, *_): return obj.type.name +def resolve_data_cube_type(obj, *_): + return obj.type + + union_types = ( UnionType("TaggableResource", resolve_type_by_class_name), UnionType("CreateCustomProductResult", resolve_type_by_class_name), @@ -193,3 +199,12 @@ def resolve_location_type(obj, *_): InterfaceType("Location", resolve_location_type), InterfaceType("ItemsCollection", resolve_type_by_class_name), ) + + +# Types for public API +public_api_types = ( + shareable_link_query, + resolved_link, + UnionType("ResolvedLinkResult", resolve_type_by_class_name), + InterfaceType("DataCube", resolve_data_cube_type), +) diff --git a/back/boxtribute_server/graph_ql/definitions/protected/queries.graphql b/back/boxtribute_server/graph_ql/definitions/protected/queries.graphql index cbba3ffba..835e84f2e 100644 --- a/back/boxtribute_server/graph_ql/definitions/protected/queries.graphql +++ b/back/boxtribute_server/graph_ql/definitions/protected/queries.graphql @@ -59,9 +59,6 @@ type Query { " Return various metrics about stock and beneficiaries for client's organisation. " metrics(organisationId: ID): Metrics - # Redundant definition of statviz-related queries (see ../public/queries.graphql). - # ariadne's GraphQL parser does not allow to define multiple 'type Query's - # and import them into one schema beneficiaryDemographics(baseId: Int!): BeneficiaryDemographicsData createdBoxes(baseId: Int!): CreatedBoxesData topProductsCheckedOut(baseId: Int!): TopProductsCheckedOutData diff --git a/back/boxtribute_server/graph_ql/definitions/public/queries.graphql b/back/boxtribute_server/graph_ql/definitions/public/queries.graphql index 55defbef2..69ecaa5df 100644 --- a/back/boxtribute_server/graph_ql/definitions/public/queries.graphql +++ b/back/boxtribute_server/graph_ql/definitions/public/queries.graphql @@ -1,9 +1,3 @@ -# When updating the definitions, remember to also update ../protected/queries.graphql type Query { - beneficiaryDemographics(baseId: Int!): BeneficiaryDemographicsData - createdBoxes(baseId: Int!): CreatedBoxesData - topProductsCheckedOut(baseId: Int!): TopProductsCheckedOutData - topProductsDonated(baseId: Int!): TopProductsDonatedData - movedBoxes(baseId: Int!): MovedBoxesData - stockOverview(baseId: Int!): StockOverviewData + resolveLink(code: String!): ResolvedLinkResult } diff --git a/back/boxtribute_server/graph_ql/definitions/public/types.graphql b/back/boxtribute_server/graph_ql/definitions/public/types.graphql index 5217136ab..d5bcef688 100644 --- a/back/boxtribute_server/graph_ql/definitions/public/types.graphql +++ b/back/boxtribute_server/graph_ql/definitions/public/types.graphql @@ -180,3 +180,22 @@ type TargetDimensionInfo { name: String type: TargetType } + +type ResolvedLink { + id: ID! + " A unique SHA256 hash (hex format of length 64) " + code: String! + baseId: Int! + urlParameters: String + view: ShareableView! + validUntil: Datetime + data: [DataCube!] +} + +type UnknownLinkError { + code: String! +} +type ExpiredLinkError { + validUntil: Datetime! +} +union ResolvedLinkResult = ResolvedLink | ExpiredLinkError | UnknownLinkError diff --git a/back/boxtribute_server/graph_ql/schema.py b/back/boxtribute_server/graph_ql/schema.py index a6851e4fa..31743f2d7 100644 --- a/back/boxtribute_server/graph_ql/schema.py +++ b/back/boxtribute_server/graph_ql/schema.py @@ -1,10 +1,10 @@ from ariadne import make_executable_schema -from ..business_logic.statistics.queries import public_query as statistics_query from .bindables import ( interface_types, mutation_types, object_types, + public_api_types, query_types, union_types, ) @@ -44,7 +44,8 @@ public_api_schema = make_executable_schema( public_api_definitions, date_scalar, - statistics_query, + datetime_scalar, + *public_api_types, *enum_types, convert_names_case=True, ) diff --git a/back/boxtribute_server/routes.py b/back/boxtribute_server/routes.py index 5243d3c6e..09d04b0ac 100644 --- a/back/boxtribute_server/routes.py +++ b/back/boxtribute_server/routes.py @@ -8,13 +8,21 @@ from .auth import request_jwt, requires_auth from .authz import check_user_beta_level -from .blueprints import API_GRAPHQL_PATH, APP_GRAPHQL_PATH, CRON_PATH, api_bp, app_bp +from .blueprints import ( + API_GRAPHQL_PATH, + APP_GRAPHQL_PATH, + CRON_PATH, + SHARED_GRAPHQL_PATH, + api_bp, + app_bp, + shared_bp, +) from .bridges import authenticate_auth0_log_stream, send_transformed_logs_to_slack from .exceptions import AuthenticationFailed from .graph_ql.execution import execute_async from .graph_ql.schema import full_api_schema, public_api_schema, query_api_schema from .logging import API_CONTEXT, WEBAPP_CONTEXT, log_request_to_gcloud -from .utils import in_development_environment +from .utils import in_development_environment, in_staging_environment # Allowed headers for CORS CORS_HEADERS = ["Content-Type", "Authorization", "x-clacks-overhead"] @@ -43,24 +51,25 @@ def query_api_server(): return execute_async(schema=query_api_schema, introspection=True) -@api_bp.post("/public") +@shared_bp.post(SHARED_GRAPHQL_PATH) @cross_origin( # Allow dev localhost ports origins=[ "http://localhost:5005", "http://localhost:3000", "http://localhost:5173", + "https://shared-staging.boxtribute.org", + "https://shared-staging-dot-dropapp-242214.ew.r.appspot.com", ], methods=["POST"], allow_headers="*" if in_development_environment() else CORS_HEADERS, ) def public_api_server(): - # Block access unless in development - if not in_development_environment(): + if not in_development_environment() and not in_staging_environment(): return {"error": "No permission to access public API"}, 401 log_request_to_gcloud(context=API_CONTEXT) - return execute_async(schema=public_api_schema, introspection=True) + return execute_async(schema=public_api_schema, introspection=False) @api_bp.post("/token") @@ -112,8 +121,10 @@ def graphql_explorer(): return EXPLORER_HTML, 200 -@api_bp.get("/public") -def public(): +@shared_bp.get(SHARED_GRAPHQL_PATH) +def public_graphql_explorer(): + if not in_development_environment() and not in_staging_environment(): + return {"error": "No permission to access public API"}, 401 return EXPLORER_HTML, 200 diff --git a/back/boxtribute_server/shared_main.py b/back/boxtribute_server/shared_main.py new file mode 100644 index 000000000..9d9251ae9 --- /dev/null +++ b/back/boxtribute_server/shared_main.py @@ -0,0 +1,6 @@ +"""Main entry point for public BE""" + +from .app import main +from .routes import shared_bp + +app = main(shared_bp) diff --git a/back/test/auth0_integration_tests/test_permissions.py b/back/test/auth0_integration_tests/test_permissions.py index 8a8f4df86..39fc9e362 100644 --- a/back/test/auth0_integration_tests/test_permissions.py +++ b/back/test/auth0_integration_tests/test_permissions.py @@ -6,6 +6,7 @@ get_authorization_header, ) from boxtribute_server.auth import CurrentUser, decode_jwt, get_public_key +from boxtribute_server.blueprints import SHARED_GRAPHQL_PATH from utils import ( assert_forbidden_request, assert_successful_request, @@ -242,9 +243,10 @@ def test_check_beta_feature_access(dropapp_dev_client, mocker): def test_check_public_api_access(dropapp_dev_client, monkeypatch): - monkeypatch.setenv("CI", "false") monkeypatch.setenv("ENVIRONMENT", "production") - query = "query { beneficiaryDemographics(baseId: 1) { count } }" - response = assert_unauthorized(dropapp_dev_client, query, endpoint="public") + query = 'query { resolveLink(code: "foo") { __typename } }' + response = assert_unauthorized( + dropapp_dev_client, query, endpoint=SHARED_GRAPHQL_PATH[1:] + ) assert response.json["error"] == "No permission to access public API" diff --git a/back/test/conftest.py b/back/test/conftest.py index 9cc533f07..f3655ec8a 100644 --- a/back/test/conftest.py +++ b/back/test/conftest.py @@ -23,7 +23,7 @@ # b) all data models are registered as db.Model subclasses (because the GraphQL schema # is imported into the routes module which in turn imports all data models down the # line); this is relevant for setup_models() to work -from boxtribute_server.routes import api_bp, app_bp +from boxtribute_server.routes import api_bp, app_bp, shared_bp # Imports fixtures into tests from data import * # noqa: F401,F403 @@ -112,7 +112,9 @@ def read_only_client(mysql_testing_database_read_only): app client that simulates sending requests to the app. The client's authentication and authorization may be separately defined or patched. """ - with _create_app(mysql_testing_database_read_only, api_bp, app_bp) as app: + with _create_app( + mysql_testing_database_read_only, api_bp, app_bp, shared_bp + ) as app: yield app.test_client() @@ -152,7 +154,7 @@ def mysql_dev_database(monkeypatch): monkeypatch.setenv("MYSQL_SOCKET", "") monkeypatch.setenv("MYSQL_REPLICA_SOCKET", "") - app = main(api_bp, app_bp) + app = main(api_bp, app_bp, shared_bp) app.testing = True with db.database.bind_ctx(MODELS): diff --git a/back/test/data/__init__.py b/back/test/data/__init__.py index db2449e1b..ddc5cb1de 100644 --- a/back/test/data/__init__.py +++ b/back/test/data/__init__.py @@ -72,6 +72,7 @@ qr_code_for_not_delivered_box, qr_code_without_box, ) +from .shareable_link import expired_link, shareable_link from .shipment import ( another_shipment, canceled_shipment, @@ -172,6 +173,7 @@ "distro_spot5_distribution_events", "distro_spot5_distribution_events_before_return_state", "distro_spot5_distribution_events_in_return_state", + "expired_link", "expired_transfer_agreement", "god_user", "gram_unit", @@ -204,6 +206,7 @@ "removed_shipment_detail", "reviewed_transfer_agreement", "sent_shipment", + "shareable_link", "shipments", "size_ranges", "standard_products", @@ -239,6 +242,7 @@ "transfer_agreement", "shipment", "tag", + "shareable_link", ] diff --git a/back/test/data/shareable_link.py b/back/test/data/shareable_link.py new file mode 100644 index 000000000..9a0913430 --- /dev/null +++ b/back/test/data/shareable_link.py @@ -0,0 +1,51 @@ +import hashlib +from datetime import timedelta + +import pytest +from boxtribute_server.enums import ShareableView +from boxtribute_server.models.definitions.shareable_link import ShareableLink +from boxtribute_server.models.utils import utcnow + +from .user import default_user_data + +now = utcnow() +one_week_from_now = now + timedelta(weeks=1) + + +def data(): + return [ + { + "id": 1, + "code": hashlib.sha256(b"1").hexdigest(), + "valid_until": one_week_from_now, + "view": ShareableView.StatvizDashboard, + "base_id": 1, + "url_parameters": "?filter=foo", + "created_on": now, + "created_by": default_user_data()["id"], + }, + { + "id": 2, + "code": hashlib.sha256(b"2").hexdigest(), + "valid_until": now - timedelta(weeks=1), + "view": ShareableView.StatvizDashboard, + "base_id": 1, + "url_parameters": None, + "created_on": now - timedelta(weeks=2), + "created_by": default_user_data()["id"], + }, + ] + + +@pytest.fixture +def shareable_link(): + return data()[0] + + +@pytest.fixture +def expired_link(): + return data()[1] + + +def create(): + ShareableLink.insert_many(data()).execute() diff --git a/back/test/endpoint_tests/test_shareable_link.py b/back/test/endpoint_tests/test_shareable_link.py index f7131be20..ebdb61a19 100644 --- a/back/test/endpoint_tests/test_shareable_link.py +++ b/back/test/endpoint_tests/test_shareable_link.py @@ -74,3 +74,56 @@ def test_shareable_link_mutations(client, default_base, mocker): }} }}""" link = assert_successful_request(client, mutation) assert link == {"date": past_valid_until + "T00:00:00+00:00"} + + +def test_shareable_link_queries(read_only_client, shareable_link, expired_link): + code = shareable_link["code"] + query = f"""query {{ resolveLink(code: "{code}") {{ + ...on ResolvedLink {{ + code + validUntil + view + baseId + urlParameters + data {{ + ...on BeneficiaryDemographicsData {{ + demographicsFacts: facts {{ age }} + }} + ...on CreatedBoxesData {{ + createdBoxesFacts: facts {{ createdOn }} + }} + ...on MovedBoxesData {{ + movedBoxesFacts: facts {{ movedOn }} + }} + ...on StockOverviewData {{ + stockOverviewFacts: facts {{ boxState }} + }} + }} + }} }} }}""" + response = assert_successful_request(read_only_client, query, endpoint="public") + data = response.pop("data") + assert response == { + "code": code, + "validUntil": shareable_link["valid_until"].isoformat(), + "view": ShareableView.StatvizDashboard.name, + "baseId": shareable_link["base_id"], + "urlParameters": shareable_link["url_parameters"], + } + assert len(data[0]["demographicsFacts"]) > 0 + assert len(data[1]["createdBoxesFacts"]) > 0 + assert len(data[2]["movedBoxesFacts"]) > 0 + assert len(data[3]["stockOverviewFacts"]) > 0 + + code = expired_link["code"] + query = f"""query {{ resolveLink(code: "{code}") {{ + ...on ExpiredLinkError {{ validUntil }} + }} }}""" + response = assert_successful_request(read_only_client, query, endpoint="public") + assert response == {"validUntil": expired_link["valid_until"].isoformat()} + + code = "unknown" + query = f"""query {{ resolveLink(code: "{code}") {{ + ...on UnknownLinkError {{ code }} + }} }}""" + response = assert_successful_request(read_only_client, query, endpoint="public") + assert response == {"code": code} diff --git a/back/test/endpoint_tests/test_simple.py b/back/test/endpoint_tests/test_simple.py index 7ee5ca6ec..fda849235 100644 --- a/back/test/endpoint_tests/test_simple.py +++ b/back/test/endpoint_tests/test_simple.py @@ -9,7 +9,12 @@ def test_private_endpoint(read_only_client, endpoint): assert EXPLORER_TITLE in response.data.decode() -def test_public_endpoint(read_only_client): +def test_public_endpoint(read_only_client, monkeypatch): response = read_only_client.get("/public") assert response.status_code == 200 assert EXPLORER_TITLE in response.data.decode() + + monkeypatch.setenv("ENVIRONMENT", "production") + response = read_only_client.get("/public") + assert response.status_code == 401 + assert response.json["error"] == "No permission to access public API" diff --git a/back/test/endpoint_tests/test_statistics.py b/back/test/endpoint_tests/test_statistics.py index 5f28b92f0..750a20f27 100644 --- a/back/test/endpoint_tests/test_statistics.py +++ b/back/test/endpoint_tests/test_statistics.py @@ -1,24 +1,16 @@ from datetime import date -import pytest from auth import mock_user_for_request from boxtribute_server.enums import BoxState, ProductGender, TargetType from boxtribute_server.models.utils import compute_age -from utils import ( - assert_bad_user_input, - assert_forbidden_request, - assert_successful_request, -) +from utils import assert_forbidden_request, assert_successful_request -@pytest.mark.parametrize("endpoint", ["graphql", "public"]) -def test_query_beneficiary_demographics( - read_only_client, tags, default_beneficiary, endpoint -): +def test_query_beneficiary_demographics(read_only_client, tags, default_beneficiary): query = """query { beneficiaryDemographics(baseId: 1) { facts { gender age createdOn deletedOn count tagIds } dimensions { tag { id name color } } } }""" - response = assert_successful_request(read_only_client, query, endpoint=endpoint) + response = assert_successful_request(read_only_client, query, endpoint="graphql") age = compute_age(default_beneficiary["date_of_birth"]) assert response["facts"] == [ { @@ -62,9 +54,8 @@ def test_query_beneficiary_demographics( } -@pytest.mark.parametrize("endpoint", ["graphql", "public"]) def test_query_created_boxes( - read_only_client, base1_undeleted_products, product_categories, tags, endpoint + read_only_client, base1_undeleted_products, product_categories, tags ): query = """query { createdBoxes(baseId: 1) { facts { @@ -75,7 +66,7 @@ def test_query_created_boxes( category { id name } tag { id } } } }""" - data = assert_successful_request(read_only_client, query, endpoint=endpoint) + data = assert_successful_request(read_only_client, query, endpoint="graphql") facts = data.pop("facts") assert len(facts) == 4 assert facts[0]["boxesCount"] == 12 @@ -102,7 +93,6 @@ def test_query_created_boxes( } -@pytest.mark.parametrize("endpoint", ["graphql", "public"]) def test_query_top_products( read_only_client, default_product, @@ -113,12 +103,11 @@ def test_query_top_products( default_box, default_size, another_size, - endpoint, ): query = """query { topProductsCheckedOut(baseId: 1) { facts { checkedOutOn productId categoryId rank itemsCount } dimensions { product { id name } } } }""" - data = assert_successful_request(read_only_client, query, endpoint=endpoint) + data = assert_successful_request(read_only_client, query, endpoint="graphql") assert data == { "facts": [ { @@ -153,7 +142,7 @@ def test_query_top_products( query = """query { topProductsDonated(baseId: 1) { facts { createdOn donatedOn sizeId productId categoryId rank itemsCount } dimensions { product { id name } size { id name } } } }""" - data = assert_successful_request(read_only_client, query, endpoint=endpoint) + data = assert_successful_request(read_only_client, query, endpoint="graphql") assert data == { "facts": [ { @@ -197,9 +186,8 @@ def test_query_top_products( } -@pytest.mark.parametrize("endpoint", ["graphql", "public"]) def test_query_moved_boxes( - read_only_client, default_location, another_base, another_organisation, endpoint + read_only_client, default_location, another_base, another_organisation ): query = """query { movedBoxes(baseId: 1) { facts { @@ -208,7 +196,7 @@ def test_query_moved_boxes( } dimensions { target { id name type } } } }""" - data = assert_successful_request(read_only_client, query, endpoint=endpoint) + data = assert_successful_request(read_only_client, query, endpoint="graphql") location_name = default_location["name"] base_name = another_base["name"] org_name = another_organisation["name"] @@ -321,16 +309,13 @@ def test_query_moved_boxes( } -@pytest.mark.parametrize("endpoint", ["graphql", "public"]) -def test_query_stock_overview( - read_only_client, default_product, default_location, endpoint -): +def test_query_stock_overview(read_only_client, default_product, default_location): query = """query { stockOverview(baseId: 1) { facts { categoryId productName gender sizeId locationId boxState tagIds absoluteMeasureValue dimensionId itemsCount boxesCount } dimensions { location { id name } dimension { id name } } } }""" - data = assert_successful_request(read_only_client, query, endpoint=endpoint) + data = assert_successful_request(read_only_client, query, endpoint="graphql") product_name = default_product["name"].strip().lower() assert data["dimensions"] == { "location": [{"id": default_location["id"], "name": default_location["name"]}], @@ -518,15 +503,3 @@ def test_authorization(read_only_client, mocker): mock_user_for_request(mocker, permissions=["tag_relation:read"]) query = "query { beneficiaryDemographics(baseId: 1) { facts { age } } }" assert_forbidden_request(read_only_client, query) - - -def test_public_query_validation(read_only_client): - for query in [ - "query { beneficiaryDemographics(baseId: 99) { facts { age } } }", - "query { createdBoxes(baseId: 99) { facts { productId } } }", - "query { topProductsCheckedOut(baseId: 99) { facts { productId } } }", - "query { topProductsDonated(baseId: 99) { facts { productId } } }", - "query { movedBoxes(baseId: 99) { facts { categoryId } } }", - "query { stockOverview(baseId: 99) { facts { categoryId } } }", - ]: - assert_bad_user_input(read_only_client, query, endpoint="public") diff --git a/graphql/generated/graphql-env.d.ts b/graphql/generated/graphql-env.d.ts index d081fa7cf..01efbd48c 100644 --- a/graphql/generated/graphql-env.d.ts +++ b/graphql/generated/graphql-env.d.ts @@ -55,6 +55,7 @@ export type introspection_types = { 'EmptyNameError': { kind: 'OBJECT'; name: 'EmptyNameError'; fields: { '_': { name: '_'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; }; }; 'EnableStandardProductResult': { kind: 'UNION'; name: 'EnableStandardProductResult'; fields: {}; possibleTypes: 'InsufficientPermissionError' | 'InvalidPriceError' | 'OutdatedStandardProductVersionError' | 'Product' | 'ResourceDoesNotExistError' | 'StandardProductAlreadyEnabledForBaseError' | 'UnauthorizedForBaseError'; }; 'EnableStandardProductsResult': { kind: 'UNION'; name: 'EnableStandardProductsResult'; fields: {}; possibleTypes: 'InsufficientPermissionError' | 'ProductsResult' | 'UnauthorizedForBaseError'; }; + 'ExpiredLinkError': { kind: 'OBJECT'; name: 'ExpiredLinkError'; fields: { 'validUntil': { name: 'validUntil'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Datetime'; ofType: null; }; } }; }; }; 'FilterBaseInput': { kind: 'INPUT_OBJECT'; name: 'FilterBaseInput'; isOneOf: false; inputFields: [{ name: 'includeDeleted'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: "false" }]; }; 'FilterBeneficiaryInput': { kind: 'INPUT_OBJECT'; name: 'FilterBeneficiaryInput'; isOneOf: false; inputFields: [{ name: 'createdFrom'; type: { kind: 'SCALAR'; name: 'Date'; ofType: null; }; defaultValue: null }, { name: 'createdUntil'; type: { kind: 'SCALAR'; name: 'Date'; ofType: null; }; defaultValue: null }, { name: 'active'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'isVolunteer'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'registered'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; defaultValue: null }, { name: 'pattern'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }]; }; 'FilterBoxInput': { kind: 'INPUT_OBJECT'; name: 'FilterBoxInput'; isOneOf: false; inputFields: [{ name: 'states'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'BoxState'; ofType: null; }; }; }; defaultValue: null }, { name: 'lastModifiedFrom'; type: { kind: 'SCALAR'; name: 'Date'; ofType: null; }; defaultValue: null }, { name: 'lastModifiedUntil'; type: { kind: 'SCALAR'; name: 'Date'; ofType: null; }; defaultValue: null }, { name: 'productGender'; type: { kind: 'ENUM'; name: 'ProductGender'; ofType: null; }; defaultValue: null }, { name: 'productCategoryId'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'productId'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'sizeId'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }, { name: 'tagIds'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; }; }; defaultValue: null }]; }; @@ -96,6 +97,8 @@ export type introspection_types = { 'QrCode': { kind: 'OBJECT'; name: 'QrCode'; fields: { 'box': { name: 'box'; type: { kind: 'UNION'; name: 'BoxResult'; ofType: null; } }; 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'createdOn': { name: 'createdOn'; type: { kind: 'SCALAR'; name: 'Datetime'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; }; }; 'QrCodeResult': { kind: 'UNION'; name: 'QrCodeResult'; fields: {}; possibleTypes: 'InsufficientPermissionError' | 'QrCode' | 'ResourceDoesNotExistError'; }; 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'base': { name: 'base'; type: { kind: 'OBJECT'; name: 'Base'; ofType: null; } }; 'bases': { name: 'bases'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Base'; ofType: null; }; }; }; } }; 'beneficiaries': { name: 'beneficiaries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'BeneficiaryPage'; ofType: null; }; } }; 'beneficiary': { name: 'beneficiary'; type: { kind: 'OBJECT'; name: 'Beneficiary'; ofType: null; } }; 'beneficiaryDemographics': { name: 'beneficiaryDemographics'; type: { kind: 'OBJECT'; name: 'BeneficiaryDemographicsData'; ofType: null; } }; 'box': { name: 'box'; type: { kind: 'OBJECT'; name: 'Box'; ofType: null; } }; 'boxes': { name: 'boxes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'BoxPage'; ofType: null; }; } }; 'createdBoxes': { name: 'createdBoxes'; type: { kind: 'OBJECT'; name: 'CreatedBoxesData'; ofType: null; } }; 'distributionEvent': { name: 'distributionEvent'; type: { kind: 'OBJECT'; name: 'DistributionEvent'; ofType: null; } }; 'distributionEventsTrackingGroup': { name: 'distributionEventsTrackingGroup'; type: { kind: 'OBJECT'; name: 'DistributionEventsTrackingGroup'; ofType: null; } }; 'distributionSpot': { name: 'distributionSpot'; type: { kind: 'OBJECT'; name: 'DistributionSpot'; ofType: null; } }; 'distributionSpots': { name: 'distributionSpots'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'DistributionSpot'; ofType: null; }; }; }; } }; 'location': { name: 'location'; type: { kind: 'OBJECT'; name: 'ClassicLocation'; ofType: null; } }; 'locations': { name: 'locations'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ClassicLocation'; ofType: null; }; }; }; } }; 'metrics': { name: 'metrics'; type: { kind: 'OBJECT'; name: 'Metrics'; ofType: null; } }; 'movedBoxes': { name: 'movedBoxes'; type: { kind: 'OBJECT'; name: 'MovedBoxesData'; ofType: null; } }; 'organisation': { name: 'organisation'; type: { kind: 'OBJECT'; name: 'Organisation'; ofType: null; } }; 'organisations': { name: 'organisations'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Organisation'; ofType: null; }; }; }; } }; 'packingListEntry': { name: 'packingListEntry'; type: { kind: 'OBJECT'; name: 'PackingListEntry'; ofType: null; } }; 'product': { name: 'product'; type: { kind: 'OBJECT'; name: 'Product'; ofType: null; } }; 'productCategories': { name: 'productCategories'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ProductCategory'; ofType: null; }; }; }; } }; 'productCategory': { name: 'productCategory'; type: { kind: 'OBJECT'; name: 'ProductCategory'; ofType: null; } }; 'products': { name: 'products'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ProductPage'; ofType: null; }; } }; 'qrCode': { name: 'qrCode'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'UNION'; name: 'QrCodeResult'; ofType: null; }; } }; 'qrExists': { name: 'qrExists'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'shipment': { name: 'shipment'; type: { kind: 'OBJECT'; name: 'Shipment'; ofType: null; } }; 'shipments': { name: 'shipments'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Shipment'; ofType: null; }; }; }; } }; 'sizeRanges': { name: 'sizeRanges'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'SizeRange'; ofType: null; }; }; }; } }; 'standardProduct': { name: 'standardProduct'; type: { kind: 'UNION'; name: 'StandardProductResult'; ofType: null; } }; 'standardProducts': { name: 'standardProducts'; type: { kind: 'UNION'; name: 'StandardProductsResult'; ofType: null; } }; 'stockOverview': { name: 'stockOverview'; type: { kind: 'OBJECT'; name: 'StockOverviewData'; ofType: null; } }; 'tag': { name: 'tag'; type: { kind: 'OBJECT'; name: 'Tag'; ofType: null; } }; 'tags': { name: 'tags'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'Tag'; ofType: null; }; }; }; } }; 'topProductsCheckedOut': { name: 'topProductsCheckedOut'; type: { kind: 'OBJECT'; name: 'TopProductsCheckedOutData'; ofType: null; } }; 'topProductsDonated': { name: 'topProductsDonated'; type: { kind: 'OBJECT'; name: 'TopProductsDonatedData'; ofType: null; } }; 'transferAgreement': { name: 'transferAgreement'; type: { kind: 'OBJECT'; name: 'TransferAgreement'; ofType: null; } }; 'transferAgreements': { name: 'transferAgreements'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'TransferAgreement'; ofType: null; }; }; }; } }; 'user': { name: 'user'; type: { kind: 'OBJECT'; name: 'User'; ofType: null; } }; 'users': { name: 'users'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'User'; ofType: null; }; }; }; } }; }; }; + 'ResolvedLink': { kind: 'OBJECT'; name: 'ResolvedLink'; fields: { 'baseId': { name: 'baseId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'data': { name: 'data'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INTERFACE'; name: 'DataCube'; ofType: null; }; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'urlParameters': { name: 'urlParameters'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'validUntil': { name: 'validUntil'; type: { kind: 'SCALAR'; name: 'Datetime'; ofType: null; } }; 'view': { name: 'view'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ShareableView'; ofType: null; }; } }; }; }; + 'ResolvedLinkResult': { kind: 'UNION'; name: 'ResolvedLinkResult'; fields: {}; possibleTypes: 'ExpiredLinkError' | 'ResolvedLink' | 'UnknownLinkError'; }; 'ResourceDoesNotExistError': { kind: 'OBJECT'; name: 'ResourceDoesNotExistError'; fields: { 'id': { name: 'id'; type: { kind: 'SCALAR'; name: 'ID'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'Result': { kind: 'UNION'; name: 'Result'; fields: {}; possibleTypes: 'BeneficiaryDemographicsResult' | 'CreatedBoxesResult' | 'MovedBoxesResult' | 'StockOverviewResult' | 'TopProductsCheckedOutResult' | 'TopProductsDonatedResult'; }; 'ShareableLink': { kind: 'OBJECT'; name: 'ShareableLink'; fields: { 'baseId': { name: 'baseId'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'createdBy': { name: 'createdBy'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'User'; ofType: null; }; } }; 'createdOn': { name: 'createdOn'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Datetime'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'urlParameters': { name: 'urlParameters'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'validUntil': { name: 'validUntil'; type: { kind: 'SCALAR'; name: 'Datetime'; ofType: null; } }; 'view': { name: 'view'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'ShareableView'; ofType: null; }; } }; }; }; @@ -150,6 +153,7 @@ export type introspection_types = { 'UnauthorizedForBaseError': { kind: 'OBJECT'; name: 'UnauthorizedForBaseError'; fields: { 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'organisationName': { name: 'organisationName'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'UnboxedItemsCollection': { kind: 'OBJECT'; name: 'UnboxedItemsCollection'; fields: { 'distributionEvent': { name: 'distributionEvent'; type: { kind: 'OBJECT'; name: 'DistributionEvent'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'label': { name: 'label'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'location': { name: 'location'; type: { kind: 'INTERFACE'; name: 'Location'; ofType: null; } }; 'numberOfItems': { name: 'numberOfItems'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'product': { name: 'product'; type: { kind: 'OBJECT'; name: 'Product'; ofType: null; } }; 'size': { name: 'size'; type: { kind: 'OBJECT'; name: 'Size'; ofType: null; } }; }; }; 'Unit': { kind: 'OBJECT'; name: 'Unit'; fields: { 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'symbol': { name: 'symbol'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; + 'UnknownLinkError': { kind: 'OBJECT'; name: 'UnknownLinkError'; fields: { 'code': { name: 'code'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'User': { kind: 'OBJECT'; name: 'User'; fields: { 'bases': { name: 'bases'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Base'; ofType: null; }; } }; 'email': { name: 'email'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'lastAction': { name: 'lastAction'; type: { kind: 'SCALAR'; name: 'Datetime'; ofType: null; } }; 'lastLogin': { name: 'lastLogin'; type: { kind: 'SCALAR'; name: 'Datetime'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'organisation': { name: 'organisation'; type: { kind: 'OBJECT'; name: 'Organisation'; ofType: null; } }; 'validFirstDay': { name: 'validFirstDay'; type: { kind: 'SCALAR'; name: 'Date'; ofType: null; } }; 'validLastDay': { name: 'validLastDay'; type: { kind: 'SCALAR'; name: 'Date'; ofType: null; } }; }; }; }; diff --git a/graphql/generated/schema.graphql b/graphql/generated/schema.graphql index 0cc11d2d4..8ccf4e9a7 100644 --- a/graphql/generated/schema.graphql +++ b/graphql/generated/schema.graphql @@ -552,9 +552,6 @@ type Query { " Return various metrics about stock and beneficiaries for client's organisation. " metrics(organisationId: ID): Metrics - # Redundant definition of statviz-related queries (see ../public/queries.graphql). - # ariadne's GraphQL parser does not allow to define multiple 'type Query's - # and import them into one schema beneficiaryDemographics(baseId: Int!): BeneficiaryDemographicsData createdBoxes(baseId: Int!): CreatedBoxesData topProductsCheckedOut(baseId: Int!): TopProductsCheckedOutData @@ -1481,3 +1478,22 @@ type TargetDimensionInfo { name: String type: TargetType } + +type ResolvedLink { + id: ID! + " A unique SHA256 hash (hex format of length 64) " + code: String! + baseId: Int! + urlParameters: String + view: ShareableView! + validUntil: Datetime + data: [DataCube!] +} + +type UnknownLinkError { + code: String! +} +type ExpiredLinkError { + validUntil: Datetime! +} +union ResolvedLinkResult = ResolvedLink | ExpiredLinkError | UnknownLinkError