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 postcode enrichment API #46

Merged
merged 33 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cbe2b41
Fix a bug with NextJS
janbaykara Apr 17, 2024
ee62065
MVP 'query postcode' route for first party data (postcodes, electoral…
janbaykara Apr 17, 2024
246eb12
Simplify API structure, add bulk fetch field and data loaders
janbaykara Apr 17, 2024
d2a32c5
Add custom data sources to the enrichment API
janbaykara Apr 17, 2024
b4d3900
Catch postcode bugs
janbaykara Apr 17, 2024
fe89b94
Fix dataloader filtering bug
janbaykara Apr 17, 2024
7a05020
Linting
janbaykara Apr 17, 2024
6efd5b0
Add API tests
janbaykara Apr 17, 2024
da3411b
API token management backend
janbaykara Apr 18, 2024
f07cde2
Ratelimiting
janbaykara Apr 18, 2024
7ccf707
Authenticate guard for enrichment API endpoints
janbaykara Apr 18, 2024
589c813
Revert "Ratelimiting"
janbaykara Apr 18, 2024
884469d
Fix some bugs
janbaykara Apr 18, 2024
89dd438
Add telemetry to Strawberry
janbaykara Apr 18, 2024
0b51b94
Linting
janbaykara Apr 18, 2024
ce84cc7
Add UI for token management and docs
janbaykara Apr 18, 2024
75b9c85
Revert "Add telemetry to Strawberry"
janbaykara Apr 18, 2024
04e2a34
Linting
janbaykara Apr 18, 2024
d3aee38
Update poetry.lock
janbaykara Apr 18, 2024
dd2e262
Preload CSS into graphiql
janbaykara Apr 18, 2024
f57609b
Merge remote-tracking branch 'origin/main' into feature/meep-293-publ…
janbaykara Apr 18, 2024
b7cb8a2
Fix a bug in the schema
janbaykara Apr 18, 2024
da93e29
Basic analytics for public API
janbaykara Apr 18, 2024
89cc606
Linting
janbaykara Apr 18, 2024
36480f8
Fix bug
janbaykara Apr 18, 2024
2ffa7d6
Read keys from cache and throw useful errors for revoked keys
janbaykara Apr 19, 2024
c78e8c6
Add tests for API key revocation
janbaykara Apr 19, 2024
cbbe9f0
Disable posthog by default
janbaykara Apr 19, 2024
3f465d0
Tighten up tests
janbaykara Apr 19, 2024
f616f26
linting
janbaykara Apr 19, 2024
c17a8db
Catch pre-migration DB query error
janbaykara Apr 19, 2024
79c2277
fix: correct exception class in hub.apps.py
joaquimds Apr 22, 2024
640f8d3
Raise errors if cryptography env vars are not set
janbaykara Apr 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions hub/apps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
from django.apps import AppConfig
from django.db.utils import ProgrammingError


class HubConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "hub"

def ready(self):
try:
from hub.models import refresh_tokens_cache

refresh_tokens_cache()
except ProgrammingError:
# This is expected when running migrations
pass
14 changes: 9 additions & 5 deletions hub/graphql/dataloaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,16 @@ class ReverseFKWithFiltersDataLoader(dataloaders.BasicReverseFKDataLoader):
@classmethod
@sync_to_async
def load_fn(cls, keys: list[str]) -> list[list[DjangoModel]]:
filter_dict = strawberry.asdict(cls.filters)
results: list["DjangoModel"] = list(
cls.model.objects.filter(**{f"{cls.reverse_path}__in": keys})
.prefetch_related(*cls.prefetch)
.filter(**filter_dict)
unsanitised_filter_dict = strawberry.asdict(cls.filters)
filter_dict = {}
for key in unsanitised_filter_dict:
if unsanitised_filter_dict[key] is not strawberry.UNSET:
filter_dict[key] = unsanitised_filter_dict[key]
results = cls.model.objects.prefetch_related(*cls.prefetch).filter(
**{f"{cls.reverse_path}__in": keys}
)
if len(filter_dict.keys()) > 0:
results = results.filter(**filter_dict)
return [
[result for result in results if getattr(result, cls.reverse_path) == key]
for key in keys
Expand Down
File renamed without changes.
43 changes: 43 additions & 0 deletions hub/graphql/extensions/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import posthog
from gqlauth.core.utils import app_settings
from graphql import ExecutionResult as GraphQLExecutionResult
from graphql.error import GraphQLError
from strawberry.extensions import SchemaExtension

from hub.models import APIToken, get_api_token


class APIAnalyticsExtension(SchemaExtension):
def on_operation(self):
yield
request = self.execution_context.context.request
if token_str := app_settings.JWT_TOKEN_FINDER(request):
try:
signature = token_str.split(".")[2]
except IndexError:
self.execution_context.result = GraphQLExecutionResult(
data=None,
errors=[GraphQLError("Invalid auth token")],
)
return
try:
db_token = get_api_token(signature)
if db_token is not None:
if db_token.revoked:
self.execution_context.result = GraphQLExecutionResult(
data=None,
errors=[GraphQLError("Token has been revoked")],
)
if not posthog.disabled:
posthog.capture(
db_token.user_id,
"API request",
{
"operation_name": self.execution_context.operation_name,
"operation_type": self.execution_context.operation_type,
},
)
else:
print("API request run by User ID", db_token.user_id)
except APIToken.DoesNotExist:
pass
21 changes: 19 additions & 2 deletions hub/graphql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
from gqlauth.user import arg_mutations as auth_mutations
from gqlauth.user.queries import UserQueries
from graphql import GraphQLError
from strawberry.extensions import QueryDepthLimiter
from strawberry.types import ExecutionContext
from strawberry_django import mutations as django_mutations
from strawberry_django.optimizer import DjangoOptimizerExtension
from strawberry_django.permissions import IsAuthenticated

from hub import models
from hub.graphql import mutations as mutation_types
from hub.graphql.types import model_types
from hub.graphql.extensions.analytics import APIAnalyticsExtension
from hub.graphql.types import model_types, public_queries

logger = logging.getLogger(__name__)

Expand All @@ -43,7 +45,6 @@ class Query(UserQueries):
my_organisations: List[model_types.Organisation] = strawberry_django.field(
extensions=[IsAuthenticated()]
)

external_data_source: model_types.ExternalDataSource = strawberry_django.field(
extensions=[IsAuthenticated()]
)
Expand Down Expand Up @@ -87,6 +88,15 @@ class Query(UserQueries):
area: Optional[model_types.Area] = model_types.area_by_gss
dataSet: Optional[model_types.DataSet] = model_types.dataset_by_name

enrich_postcode: public_queries.PostcodeQueryResponse = strawberry.field(
resolver=public_queries.enrich_postcode,
extensions=[IsAuthenticated()],
)
enrich_postcodes: List[public_queries.PostcodeQueryResponse] = strawberry.field(
resolver=public_queries.enrich_postcodes,
extensions=[IsAuthenticated()],
)

@strawberry.field
def test_data_source(
self, info: strawberry.types.Info, input: TestDataSourceInput
Expand All @@ -100,6 +110,8 @@ def test_data_source(
else:
raise ValueError("Unsupported data source type")

list_api_tokens = public_queries.list_api_tokens


@strawberry.type
class Mutation:
Expand All @@ -108,6 +120,9 @@ class Mutation:
verify_account = auth_mutations.VerifyAccount.field
resend_activation_email = auth_mutations.ResendActivationEmail.field

create_api_token = public_queries.create_api_token
revoke_api_token = public_queries.revoke_api_token

create_external_data_source: mutation_types.CreateExternalDataSourceOutput = (
mutation_types.create_external_data_source
)
Expand Down Expand Up @@ -203,5 +218,7 @@ def process_errors(
mutation=Mutation,
extensions=[
DjangoOptimizerExtension, # not required, but highly recommended
APIAnalyticsExtension,
QueryDepthLimiter(max_depth=10),
],
)
1 change: 1 addition & 0 deletions hub/graphql/types/model_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ class FieldDefinition:
value: str = dict_key_field()
label: Optional[str] = dict_key_field()
description: Optional[str] = dict_key_field()
external_id: Optional[str] = dict_key_field()


@strawberry_django.filter(models.ExternalDataSource)
Expand Down
6 changes: 4 additions & 2 deletions hub/graphql/types/postcodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class PostcodesIOCodes:
admin_district: str = dict_key_field()
admin_county: str = dict_key_field()
admin_ward: str = dict_key_field()
parish: str = dict_key_field()
parish: Optional[str] = dict_key_field()
parliamentary_constituency: str = dict_key_field()
parliamentary_constituency_2025: str = dict_key_field()
ccg: str = dict_key_field()
Expand Down Expand Up @@ -59,4 +59,6 @@ class PostcodesIOResult:

@strawberry.field
def feature(self, info: strawberry.types.info.Info) -> PointFeature:
return PointFeature(point=Point(self.longitude, self.latitude), properties=self)
return PointFeature.from_geodjango(
point=Point(self.longitude, self.latitude), properties=self
)
187 changes: 187 additions & 0 deletions hub/graphql/types/public_queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import datetime
import json
from typing import List, Optional, cast

from django.conf import settings
from django.db.models import Q

import jwt
import pytz
import strawberry
import strawberry_django
from gqlauth.core.utils import app_settings
from gqlauth.jwt.types_ import TokenPayloadType, TokenType
from strawberry.dataloader import DataLoader
from strawberry.types.info import Info
from strawberry_django.auth.utils import get_current_user
from strawberry_django.permissions import IsAuthenticated

from hub import models
from hub.graphql.types import model_types
from hub.graphql.types.postcodes import PostcodesIOResult
from utils.postcodesIO import get_bulk_postcode_geo


@strawberry.type
class PostcodeQueryResponse:
postcode: str
loaders: strawberry.Private[models.Loaders]

@strawberry.field
async def postcodesIO(self) -> Optional[PostcodesIOResult]:
return await self.loaders["postcodesIO"].load(self.postcode)

@strawberry.field
async def constituency(self) -> Optional[model_types.Area]:
postcode_data = await self.loaders["postcodesIO"].load(self.postcode)
if postcode_data is None:
return None
id = postcode_data.codes.parliamentary_constituency
return await models.Area.objects.aget(Q(gss=id) | Q(name=id))

@strawberry.field
async def custom_source_data(
self, source: str, source_path: str, info: Info
) -> Optional[str]:
source_loader = self.loaders["source_loaders"].get(source, None)
postcode_data = await self.loaders["postcodesIO"].load(self.postcode)
if source_loader is not None and postcode_data is not None:
return await source_loader.load(
models.EnrichmentLookup(
member_id=self.postcode,
postcode_data=postcode_data,
source_id=source,
source_path=source_path,
)
)


async def enrich_postcode(postcode: str, info: Info) -> PostcodeQueryResponse:
user = get_current_user(info)
loaders = models.Loaders(
postcodesIO=DataLoader(load_fn=get_bulk_postcode_geo),
source_loaders={
str(source.id): source.data_loader_factory()
async for source in models.ExternalDataSource.objects.filter(
organisation__members__user=user,
geography_column__isnull=False,
geography_column_type__isnull=False,
).all()
},
)
return PostcodeQueryResponse(postcode=postcode, loaders=loaders)


async def enrich_postcodes(postcodes: List[str], info: Info) -> PostcodeQueryResponse:
if len(postcodes) > settings.POSTCODES_IO_BATCH_MAXIMUM:
raise ValueError(
f"Batch query takes a maximum of 100 postcodes. You provided {len(postcodes)}"
)
user = get_current_user(info)
loaders = models.Loaders(
postcodesIO=DataLoader(load_fn=get_bulk_postcode_geo),
source_loaders={
str(source.id): source.data_loader_factory()
async for source in models.ExternalDataSource.objects.filter(
organisation__members__user=user,
geography_column__isnull=False,
geography_column_type__isnull=False,
).all()
},
)
return [
PostcodeQueryResponse(postcode=postcode, loaders=loaders)
for postcode in postcodes
]


########################
# API token management
########################


@strawberry_django.type(models.APIToken)
class APIToken:
token: str
expires_at: datetime.datetime
signature: strawberry.ID
created_at: datetime.datetime
revoked: bool


@strawberry_django.mutation(extensions=[IsAuthenticated()])
def create_api_token(info: Info, expiry_days: int = 3650) -> APIToken:
user = get_current_user(info)
user_pk = app_settings.JWT_PAYLOAD_PK.python_name
pk_field = {user_pk: getattr(user, user_pk)}
expires_at = datetime.datetime.now(tz=pytz.utc) + datetime.timedelta(
days=expiry_days
)
payload = TokenPayloadType(**pk_field, exp=expires_at)
serialized = json.dumps(payload.as_dict(), sort_keys=True, indent=1)
token = TokenType(
token=str(
jwt.encode(
payload={"payload": serialized},
key=cast(str, app_settings.JWT_SECRET_KEY.value),
algorithm=app_settings.JWT_ALGORITHM,
)
),
payload=payload,
)

token_db = models.APIToken.objects.create(
signature=token.token.split(".")[2],
token=token.token,
user=user,
expires_at=expires_at,
)
models.refresh_tokens_cache()
return token_db


@strawberry_django.mutation(extensions=[IsAuthenticated()])
def revoke_api_token(signature: strawberry.ID, info: Info) -> APIToken:
token = models.APIToken.objects.get(signature=signature)
token.revoked = True
token.save()
models.refresh_tokens_cache()
return token


@strawberry_django.field(extensions=[IsAuthenticated()])
def list_api_tokens(info: Info) -> List[APIToken]:
tokens = models.APIToken.objects.filter(user=get_current_user(info), revoked=False)
return tokens


def decode_jwt(token: str) -> "TokenType":
from gqlauth.core.utils import app_settings
from gqlauth.jwt.types_ import TokenPayloadType, TokenType

decoded = json.loads(
jwt.decode(
token,
key=cast(str, app_settings.JWT_SECRET_KEY.value),
algorithms=[
app_settings.JWT_ALGORITHM,
],
)["payload"]
)

signature = token.split(".")[2]

if models.is_api_token_revoked(signature):
return ExpiredTokenType(
token=token, payload=TokenPayloadType.from_dict(decoded)
)

return TokenType(token=token, payload=TokenPayloadType.from_dict(decoded))


class ExpiredTokenType(TokenType):
def is_expired(self) -> bool:
return True

def expires_at(self) -> datetime.datetime:
return datetime.datetime.now()
Loading
Loading