Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Django + Graphene (GraphQL) read-only reporting API for OpenStack/OpenInfra summit data. It connects to an external MySQL database (`openstack_db`) and exposes a single GraphQL endpoint at `/reports`. Authentication is handled via OAuth2 token introspection against an IDP.

## Common Commands

```bash
# Local dev with Docker (recommended)
docker compose up -d # Start all services (app, MySQL, Redis)
./start_local_server.sh # Migrate + start + shell into container

# Without Docker
docker compose exec app python manage.py runserver 0.0.0.0:8003
docker compose exec app python manage.py migrate --database=openstack_db

# Tests (require openstack_db connection)
docker compose exec app python manage.py test reports_api.reports.tests.openapi_test_case

# Dependencies
pip install -r requirements.txt
```

## Architecture

### Database Design

- **Two databases**: `default` (SQLite in-memory, unused) and `openstack_db` (external MySQL with the actual summit data)
- **Read-only**: `DBRouter` (`reports_api/db_router.py`) routes all `reports` app reads to `openstack_db` and blocks writes/migrations
- All models use `managed = False` — they map to existing MySQL tables, not Django-managed schema
- Redis is used for caching (token info and GraphQL query results via `graphene_django_extras`)

### Request Flow

1. `TokenValidationMiddleware` (`reports_api/authentication.py`) validates OAuth2 bearer tokens by introspecting against the IDP, with Redis caching
2. Single URL route `/reports` serves GraphQL via `GraphQLView` with GraphiQL enabled
3. Root schema (`reports_api/schema.py`) delegates to `reports_api/reports/schema.py` which defines all queries

### Key Modules

- **`reports_api/reports/schema.py`** — All GraphQL types (`*Node`), list types (`*ListType`), serializer types (`*ModelType`), custom resolvers, and the `Query` class. This is the largest file and the main entry point for report logic.
- **`reports_api/reports/filters/model_filters.py`** — `django_filters` FilterSets for all queryable entities (speakers, presentations, events, attendees, metrics, etc.). Complex filters use subqueries and annotations.
- **`reports_api/reports/models/`** — Django models mapping to existing MySQL tables. Organized into subdirectories: `registration/`, `rsvp/`, `extra_questions/`.
- **`reports_api/reports/serializers/model_serializers.py`** — DRF serializers used by `DjangoSerializerType` for Speaker, Presentation, Attendee, etc.

### GraphQL Patterns

- Uses `graphene-django-extras` for pagination (`LimitOffsetGraphqlPagination`), list types, and serializer types
- Custom `DjangoListObjectField` subclasses (`SpeakerModelDjangoListObjectField`, `AttendeeModelDjangoListObjectField`) override `list_resolver` to inject annotations for filtering
- Raw SQL queries are used for metric aggregation (`getUniqueMetrics` in schema.py)
- `SubqueryCount` / `SubQueryCount` / `SubQueryAvg` are custom Subquery helpers used in filters and schema

### OpenAPI Documentation

- Uses `drf-spectacular` for OpenAPI 3.1 schema generation
- Endpoints: `/openapi` (schema), `/api/docs` (Swagger UI), `/api/redoc` (ReDoc)
- These paths are exempt from OAuth2 in `TokenValidationMiddleware.EXEMPT_PATHS`
- `openapi_hooks.py` tags paths as Public/Private and strips security from `/api/public/` paths
- `UNAUTHENTICATED_USER` is set to `None` in `REST_FRAMEWORK` because `django.contrib.auth` is not installed — DRF's default `AnonymousUser` would fail without it
- Tests: `reports_api/reports/tests/openapi_test_case.py`

### Environment Variables

Configured via `.env` file (see `.env.template`). Key vars: `DB_OPENSTACK_*` (database), `REDIS_*` (cache), `RS_CLIENT_*` + `IDP_*` (OAuth2), `SECRET_KEY`.
7 changes: 5 additions & 2 deletions reports_api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ class TokenValidationMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response

EXEMPT_PATHS = ('/openapi', '/api/docs', '/api/redoc')

def __call__(self, request):
#return self.get_response(request)

if request.path.rstrip('/') in self.EXEMPT_PATHS:
return self.get_response(request)

try:
access_token = TokenValidationMiddleware.get_access_token(request)
if access_token is None:
Expand Down
127 changes: 127 additions & 0 deletions reports_api/openapi_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
_GRAPHQL_PATH = '/reports'

_GRAPHQL_REQUEST_SCHEMA = {
'type': 'object',
'required': ['query'],
'properties': {
'query': {
'type': 'string',
'description': 'A GraphQL query or mutation string.',
'example': '{ speakers(summitId: 1) { results { id fullName } } }',
},
'variables': {
'type': ['object', 'null'],
'description': 'A JSON object of variable values referenced by the query.',
'additionalProperties': True,
},
'operationName': {
'type': ['string', 'null'],
'description': 'If the query contains multiple operations, the name of the one to execute.',
},
},
}

_GRAPHQL_RESPONSE_SCHEMA = {
'type': 'object',
'properties': {
'data': {
'type': ['object', 'null'],
'description': 'The data returned by the GraphQL query.',
'additionalProperties': True,
},
'errors': {
'type': ['array', 'null'],
'description': 'A list of errors that occurred during execution.',
'items': {
'type': 'object',
'properties': {
'message': {'type': 'string'},
'locations': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'line': {'type': 'integer'},
'column': {'type': 'integer'},
},
},
},
'path': {
'type': 'array',
'items': {'type': 'string'},
},
},
},
},
},
}

_GRAPHQL_PATH_ITEM = {
'post': {
'operationId': 'graphql_query',
'summary': 'Execute a GraphQL query',
'description': (
'Single GraphQL endpoint for all summit report queries. '
'Accepts a GraphQL document in the request body and returns the query result.\n\n'
'Authentication is required via OAuth2 bearer token. '
'The GraphiQL interactive explorer is available at this URL when accessed from a browser (GET).'
),
'tags': ['Private'],
'security': [{'OAuth2': []}],
'requestBody': {
'required': True,
'content': {
'application/json': {
'schema': _GRAPHQL_REQUEST_SCHEMA,
},
},
},
'responses': {
'200': {
'description': (
'GraphQL response. Note: errors during query execution are returned '
'with HTTP 200 inside the `errors` field.'
),
'content': {
'application/json': {
'schema': _GRAPHQL_RESPONSE_SCHEMA,
},
},
},
'400': {'description': 'Malformed request (e.g. missing or unparseable `query` field).'},
'401': {'description': 'Missing or invalid OAuth2 bearer token.'},
},
},
'get': {
'operationId': 'graphql_explorer',
'summary': 'Open the GraphiQL interactive explorer',
'description': 'Returns the GraphiQL browser UI for exploring the GraphQL schema interactively.',
'tags': ['Private'],
'security': [{'OAuth2': []}],
'responses': {
'200': {
'description': 'GraphiQL HTML interface.',
'content': {'text/html': {'schema': {'type': 'string'}}},
},
'401': {'description': 'Missing or invalid OAuth2 bearer token.'},
},
},
}


def custom_postprocessing_hook(result, generator, request, public):
for path, methods in result.get('paths', {}).items():
is_public = path.startswith('/api/public/')
tag = 'Public' if is_public else 'Private'
for method, operation in methods.items():
if not isinstance(operation, dict):
continue
operation['tags'] = [tag]
if is_public:
operation['security'] = []

paths = result.setdefault('paths', {})
if _GRAPHQL_PATH not in paths:
paths[_GRAPHQL_PATH] = _GRAPHQL_PATH_ITEM

return result
38 changes: 38 additions & 0 deletions reports_api/openapi_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)


# Subclass drf-spectacular views to disable throttling (which requires Redis).
# Plain function wrappers prevent django-injector from trying to inject
# drf-spectacular's type-hinted parameters.

class _SchemaView(SpectacularAPIView):
throttle_classes = []


class _SwaggerView(SpectacularSwaggerView):
throttle_classes = []


class _RedocView(SpectacularRedocView):
throttle_classes = []


_schema_view = _SchemaView.as_view()
_swagger_view = _SwaggerView.as_view(url_name='openapi-schema')
_redoc_view = _RedocView.as_view(url_name='openapi-schema')


def schema_view(request, *args, **kwargs):
return _schema_view(request, *args, **kwargs)


def swagger_view(request, *args, **kwargs):
return _swagger_view(request, *args, **kwargs)


def redoc_view(request, *args, **kwargs):
return _redoc_view(request, *args, **kwargs)
91 changes: 91 additions & 0 deletions reports_api/reports/tests/openapi_test_case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import json

import yaml
from django.test import TestCase
from django.urls import reverse


class OpenAPISchemaTests(TestCase):

SCHEMA_URL = reverse('openapi-schema')
SCHEMA_URL_JSON = SCHEMA_URL + '?format=json'
SCHEMA_URL_YAML = SCHEMA_URL + '?format=yaml'

def _get_schema(self):
response = self.client.get(self.SCHEMA_URL_JSON)
self.assertEqual(response.status_code, 200)
return json.loads(response.content)

def test_schema_default_format_returns_valid_yaml_on_default_url(self):
response = self.client.get(self.SCHEMA_URL)
self.assertEqual(response.status_code, 200)
schema = yaml.safe_load(response.content)
self.assertIn('openapi', schema)
self.assertIn('paths', schema)

def test_schema_default_format_returns_valid_yaml(self):
response = self.client.get(self.SCHEMA_URL_YAML)
self.assertEqual(response.status_code, 200)
schema = yaml.safe_load(response.content)
self.assertIn('openapi', schema)
self.assertIn('paths', schema)

def test_schema_returns_200(self):
response = self.client.get(self.SCHEMA_URL_JSON)
self.assertEqual(response.status_code, 200)

def test_schema_is_valid_json(self):
schema = self._get_schema()
self.assertIn('openapi', schema)
self.assertIn('paths', schema)
self.assertIn('info', schema)

def test_schema_version_is_3_1(self):
schema = self._get_schema()
self.assertTrue(schema['openapi'].startswith('3.1'))

def test_schema_info(self):
schema = self._get_schema()
self.assertEqual(schema['info']['title'], 'Summit Reports API')

def test_schema_contains_public_and_private_tags(self):
schema = self._get_schema()
tag_names = [t['name'] for t in schema.get('tags', [])]
self.assertIn('Public', tag_names)
self.assertIn('Private', tag_names)

def test_schema_contains_oauth2_security_scheme(self):
schema = self._get_schema()
security_schemes = schema.get('components', {}).get('securitySchemes', {})
self.assertIn('OAuth2', security_schemes)
self.assertEqual(security_schemes['OAuth2']['type'], 'oauth2')

def test_schema_has_paths_key(self):
schema = self._get_schema()
self.assertIn('paths', schema)


class SwaggerUITests(TestCase):

DOCS_URL = reverse('swagger-ui')

def test_swagger_ui_returns_200(self):
response = self.client.get(self.DOCS_URL)
self.assertEqual(response.status_code, 200)

def test_swagger_ui_contains_html(self):
response = self.client.get(self.DOCS_URL)
self.assertIn('text/html', response['Content-Type'])


class RedocTests(TestCase):

DOCS_URL = reverse('redoc')

def test_redoc_returns_200(self):
response = self.client.get(self.DOCS_URL)
self.assertEqual(response.status_code, 200)

def test_redoc_contains_html(self):
response = self.client.get(self.DOCS_URL)
self.assertIn('text/html', response['Content-Type'])
Loading