Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

public api for link resolving #1939

Merged
merged 13 commits into from
Feb 27, 2025
70 changes: 70 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -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:
14 changes: 10 additions & 4 deletions back/README.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions back/app-shared.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions back/boxtribute_server/blueprints.py
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 19 additions & 6 deletions back/boxtribute_server/business_logic/statistics/crud.py
Original file line number Diff line number Diff line change
@@ -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(
20 changes: 20 additions & 0 deletions back/boxtribute_server/business_logic/statistics/fields.py
Original file line number Diff line number Diff line change
@@ -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),
]
48 changes: 18 additions & 30 deletions back/boxtribute_server/business_logic/statistics/queries.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions back/boxtribute_server/dev_main.py
Original file line number Diff line number Diff line change
@@ -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():
10 changes: 10 additions & 0 deletions back/boxtribute_server/errors.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions back/boxtribute_server/graph_ql/bindables.py
Original file line number Diff line number Diff line change
@@ -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),
)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
}
Loading