From 6afc2d2a618d6206002f2b99e0f4c65c87e180a6 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Mon, 30 Mar 2026 12:38:49 +1100 Subject: [PATCH 1/6] add m2m mutation to GraphQL --- backend/custom/graphql_auto.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/custom/graphql_auto.py b/backend/custom/graphql_auto.py index 48462b43..6d3bd312 100644 --- a/backend/custom/graphql_auto.py +++ b/backend/custom/graphql_auto.py @@ -429,6 +429,13 @@ def __init_subclass_with_meta__( _meta=_meta, input_fields=input_fields, **options ) + @classmethod + def perform_mutate(cls, form, info): + obj = form.save() + if hasattr(form, "save_m2m"): + form.save_m2m() + return cls(errors=[], **{cls._meta.return_field_name: obj}) + @classmethod def get_form_kwargs(cls, root, info, **input): # Get file data From 1e15492dc4aeddecb747ed0902cbad66935f7f23 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Mon, 30 Mar 2026 16:29:13 +1100 Subject: [PATCH 2/6] fix(graphql): use commit=False + save_m2m() to ensure M2M fields are saved in mutations --- backend/custom/graphql_auto.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/custom/graphql_auto.py b/backend/custom/graphql_auto.py index 6d3bd312..7b021d4d 100644 --- a/backend/custom/graphql_auto.py +++ b/backend/custom/graphql_auto.py @@ -431,9 +431,9 @@ def __init_subclass_with_meta__( @classmethod def perform_mutate(cls, form, info): - obj = form.save() - if hasattr(form, "save_m2m"): - form.save_m2m() + obj = form.save(commit=False) + obj.save() + form.save_m2m() return cls(errors=[], **{cls._meta.return_field_name: obj}) @classmethod From b26e50698f8fbde465d141c6183ca98d630ee9f0 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Tue, 31 Mar 2026 15:18:07 +1100 Subject: [PATCH 3/6] feat(graphql): add reorder_tables and reorder_observation_levels mutations --- backend/apps/api/v1/graphql.py | 45 ++++++++++++++++++++++++++++++++-- backend/apps/schema.py | 2 ++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/apps/api/v1/graphql.py b/backend/apps/api/v1/graphql.py index 4c0795a4..f65707d6 100644 --- a/backend/apps/api/v1/graphql.py +++ b/backend/apps/api/v1/graphql.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from graphene import UUID, Boolean, Float, List, ObjectType, String +from graphene import UUID, Boolean, Float, List, Mutation, ObjectType, String from graphene_django import DjangoObjectType -from backend.apps.api.v1.models import Table, TableNeighbor +from backend.apps.api.v1.models import ObservationLevel, Table, TableNeighbor from backend.apps.api.v1.sql_generator import OneBigTableQueryGenerator from backend.custom.graphql_base import PlainTextNode @@ -39,6 +39,42 @@ def resolve_score(root, info): return root.score +class ReorderTables(Mutation): + """Set display order for tables within a dataset. + ids: ordered list of table UUIDs (index 0 = first).""" + + class Arguments: + ids = List(UUID, required=True) + + ok = Boolean() + errors = List(String) + + def mutate(root, info, ids): + if not info.context.user.is_staff: + return ReorderTables(ok=False, errors=["Permission denied"]) + for i, table_id in enumerate(ids): + Table.objects.filter(pk=table_id).update(order=i) + return ReorderTables(ok=True, errors=[]) + + +class ReorderObservationLevels(Mutation): + """Set display order for observation levels within a table. + ids: ordered list of ObservationLevel UUIDs (index 0 = first).""" + + class Arguments: + ids = List(UUID, required=True) + + ok = Boolean() + errors = List(String) + + def mutate(root, info, ids): + if not info.context.user.is_staff: + return ReorderObservationLevels(ok=False, errors=["Permission denied"]) + for i, ol_id in enumerate(ids): + ObservationLevel.objects.filter(pk=ol_id).update(order=i) + return ReorderObservationLevels(ok=True, errors=[]) + + class Query(ObjectType): get_table_neighbor = List( TableNeighborNode, @@ -63,3 +99,8 @@ def resolve_get_table_one_big_table_query( table, columns, include_table_translation ) return sql_query + + +class APIMutation: + reorder_tables = ReorderTables.Field() + reorder_observation_levels = ReorderObservationLevels.Field() diff --git a/backend/apps/schema.py b/backend/apps/schema.py index 1e833d65..4ef832d4 100644 --- a/backend/apps/schema.py +++ b/backend/apps/schema.py @@ -2,6 +2,7 @@ from backend.apps.account.graphql import AccountMutation from backend.apps.account_payment.graphql import Mutation as PaymentMutation from backend.apps.account_payment.graphql import Query as PaymentQuery +from backend.apps.api.v1.graphql import APIMutation from backend.apps.api.v1.graphql import Query as APIQuery from backend.apps.user_notifications.graphql import ( DeactivateAllTableUpdateNotification, @@ -16,6 +17,7 @@ extra_queries=[APIQuery, PaymentQuery, UserNotificationQuery], extra_mutations=[ AccountMutation, + APIMutation, PaymentMutation, TableUpdateNotification, DeactivateTableUpdateNotification, From 4ce0c8027ae53150555b8b7bdf227ee996f9c946 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Tue, 31 Mar 2026 15:28:03 +1100 Subject: [PATCH 4/6] feat(graphql): add reorder_columns mutation --- backend/apps/api/v1/graphql.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/apps/api/v1/graphql.py b/backend/apps/api/v1/graphql.py index f65707d6..a19cc12b 100644 --- a/backend/apps/api/v1/graphql.py +++ b/backend/apps/api/v1/graphql.py @@ -3,7 +3,7 @@ from graphene import UUID, Boolean, Float, List, Mutation, ObjectType, String from graphene_django import DjangoObjectType -from backend.apps.api.v1.models import ObservationLevel, Table, TableNeighbor +from backend.apps.api.v1.models import Column, ObservationLevel, Table, TableNeighbor from backend.apps.api.v1.sql_generator import OneBigTableQueryGenerator from backend.custom.graphql_base import PlainTextNode @@ -101,6 +101,25 @@ def resolve_get_table_one_big_table_query( return sql_query +class ReorderColumns(Mutation): + """Set display order for columns within a table. + ids: ordered list of Column UUIDs (index 0 = first).""" + + class Arguments: + ids = List(UUID, required=True) + + ok = Boolean() + errors = List(String) + + def mutate(root, info, ids): + if not info.context.user.is_staff: + return ReorderColumns(ok=False, errors=["Permission denied"]) + for i, col_id in enumerate(ids): + Column.objects.filter(pk=col_id).update(order=i) + return ReorderColumns(ok=True, errors=[]) + + class APIMutation: reorder_tables = ReorderTables.Field() reorder_observation_levels = ReorderObservationLevels.Field() + reorder_columns = ReorderColumns.Field() From 14e5d17fddc865d9a31c343ead2e8a6233bc12cd Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Wed, 1 Apr 2026 13:43:44 +1100 Subject: [PATCH 5/6] fix(graphql): register convert_form_field for ModelMultipleChoiceField M2M fields (organizations, themes, tags) were silently dropped from mutation inputs because graphene-django had no converter for ModelMultipleChoiceField. Register it to produce List(ID), enabling form.save_m2m() to persist the relationships. --- backend/custom/graphql_auto.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/custom/graphql_auto.py b/backend/custom/graphql_auto.py index 7b021d4d..3a8cc40f 100644 --- a/backend/custom/graphql_auto.py +++ b/backend/custom/graphql_auto.py @@ -12,7 +12,7 @@ from django.apps import apps from django.core.exceptions import ValidationError from django.db import models -from django.forms import ModelForm, modelform_factory +from django.forms import ModelForm, ModelMultipleChoiceField, modelform_factory from django.forms import fields as forms_fields from graphene import ( ID, @@ -502,3 +502,8 @@ def generate_form_fields(model: BaseModel): @convert_django_field.register(models.FileField) def convert_file_to_url(field, registry=None): return FileFieldScalar(description=field.help_text, required=not field.null) + + +@convert_form_field.register(ModelMultipleChoiceField) +def convert_form_field_to_list_of_id(field): + return List(ID, description=field.help_text, required=field.required) From 20d92795dfa849f07113a42aced1d8a679df394f Mon Sep 17 00:00:00 2001 From: Ricardo Dahis Date: Wed, 1 Apr 2026 21:22:33 +1100 Subject: [PATCH 6/6] feat: add CLAUDE.md file --- CLAUDE.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f7176dae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Base dos Dados backend — a Django + PostgreSQL API serving Brazil's largest public data platform. Exposes a GraphQL API (Graphene-Django) and a chatbot REST API (DRF + LangChain/LangGraph). Background tasks via Huey+Redis; full-text search via Elasticsearch+Haystack; payments via Stripe/dj-stripe. + +## Commands + +```bash +# Install +make install # poetry install +make install_precommit # install pre-commit hooks + +# Development +make run_local # makemigrations + migrate + runserver 0.0.0.0:8080 +make run_docker # docker-compose up --build --force-recreate --detach + +# Database +make migrations # python manage.py makemigrations +make migrate # python manage.py migrate +make loadfixture # python manage.py loadfixture fixture.json +make superuser + +# Lint +make lint # poetry run lint (Ruff check + format) + +# Tests +poetry run pytest # all tests +poetry run pytest backend/apps/api/v1/tests/ # specific directory +poetry run pytest -k "test_name" # single test by name + +# Docker helpers +make shell_docker # bash into api container +make logs_docker # tail logs +make stop_docker / make clean_docker +``` + +## Architecture + +### Settings +- `backend/settings/base.py` — shared config, imported by all envs +- `backend/settings/local.py` — local dev (PostgreSQL via env vars, SMTP) +- `backend/settings/remote.py` — production/staging +- `pytest.ini` uses `DJANGO_SETTINGS_MODULE=backend.settings` (resolves to `base.py`) + +### Django Apps (`backend/apps/`) + +| App | Purpose | +|-----|---------| +| `account` | Custom user model (`Account`), roles (`BDRole`/`BDGroup`), registration tokens | +| `account_auth` | JWT authentication and authorization rules | +| `account_payment` | Stripe/dj-stripe integration | +| `api/v1` | Core data catalog: Dataset, Table, Column, ObservationLevel, Coverage, Area, Entity, etc. | +| `core` | Shared models (Metadata, TaskExecution), utility management commands | +| `chatbot` | Stub app; actual chatbot logic lives in the separate `chatbot/` package | +| `user_notifications` | Table-update notification subscriptions | + +### GraphQL Auto-Generation + +The GraphQL schema is **code-generated** from Django models via `backend/custom/graphql_auto.py`. + +To expose a model in GraphQL: +1. Set `graphql_visible = True` on the model class (from `BaseModel`). +2. Control field exposure with `graphql_fields_whitelist`/`graphql_fields_blacklist`. +3. Control filtering with `graphql_filter_fields_whitelist` / `graphql_nested_filter_fields_whitelist`. +4. Set `graphql_query_decorator` (default: `anyone_required`) and `graphql_mutation_decorator` (default: `staff_member_required`) for auth. + +The master schema is assembled in `backend/apps/schema.py`: +```python +schema = build_schema( + applications=["account", "v1"], + extra_queries=[APIQuery, PaymentQuery, UserNotificationQuery], + extra_mutations=[AccountMutation, APIMutation, PaymentMutation, ...], +) +``` + +Manual queries/mutations (not auto-generated) live in each app's `graphql.py`. + +### BaseModel (`backend/custom/model.py`) + +All domain models inherit from `BaseModel` (abstract). Key attributes: +- UUID primary key, `created_at`/`updated_at` timestamps +- GraphQL visibility and field filtering class attributes +- `admin_url` property + +### Key Patterns + +- **M2M mutations**: use `commit=False` + `save_m2m()` to ensure M2M fields are saved (see recent commit history). +- **Ordered models**: `api/v1` uses `django-ordered-model` for column/table ordering; management commands `reorder_columns` and `reorder_tables` handle bulk reordering. +- **Model translation**: `django-modeltranslation` provides pt/en/es translations on selected fields. +- **Search**: Haystack + custom `AsciifoldingElasticSearchEngine`; indexes auto-update via signals. +- **Fixtures**: `loadfixture`/`dumpfixture` management commands wrap Django's fixture system with chunking support (`fixtures_chunks/`). + +### GraphQL Endpoint + +`/graphql/` — authenticated via `Bearer ` header. JWT obtained via `ObtainJSONWebToken` mutation.