Workflow Query Enhancements — Developer Guide
Table of Contents
- Overview
- Architecture
- Quick Start
- Object ID Filtering
- Aggregate Annotations
- JSON Format Reference
- View Layer Integration
- Workflow Engine Auto-Extraction
- Aggregate Metadata API
- Error Handling & Validation
- Complete Examples
- Reference
Overview
The Workflow Query Enhancements introduce two features to the SavedQuery query execution system:
-
Main Object ID Filtering — Automatically restrict query results to a single record by passing
object_idtoget_queryset(). The workflow engine auto-extracts this from theWorkflowInstanceor document it already holds, so callers never manually provide it. -
Aggregate Annotations — Compute SUM, COUNT, AVG, MIN, or MAX on child/related model fields and use those computed values in filter rules. Annotations are defined in a separate top-level
annotationsarray in thefilters_jsonand are applied via Django'sannotate()before filtering.
Problem solved: - Previously, condition step queries ran against the entire model table, producing false positives when duplicate data existed across documents. With object ID filtering, queries are scoped to the exact document being evaluated. - Previously, there was no way to filter based on aggregated values from related records (e.g., "documents where total line amount > 1000"). The annotation system enables these aggregate-aware conditions.
User outcome: 1. Workflow condition queries now automatically filter to the specific document being evaluated — no configuration needed. 2. Developers can define aggregate annotations in SavedQuery JSON to compute SUM, COUNT, AVG, MIN, MAX on related model fields. 3. Annotation aliases can be referenced in filter rules alongside regular fields. 4. The query builder UI receives aggregate metadata for presenting aggregation options to users.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ SavedQuery.get_queryset(object_id=None) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 1. Resolve model_class from content_type │ │
│ │ 2. Parse annotations from filters_json │ │
│ │ 3. model_class.objects.annotate(**annotations) │ │
│ │ 4. Build Q object from rules (existing parsers) │ │
│ │ 5. qs.filter(q) │ │
│ │ 6. If object_id: qs.filter(pk=object_id) │ │
│ └──────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ parse_annotations(model_class, annotations_list) │
│ - Maps function names → Django aggregates │
│ - Validates related_model is a valid relationship │
│ - Builds lookup strings: {related_model}__{field} │
│ - Returns {alias: Aggregate(...)} dict │
├──────────────────────────────────────────────────────────────┤
│ Auto-Extraction (Workflow Engine) │
│ evaluate_condition_for_document(document, query) │
│ → object_id = str(document.pk) │
│ → query.get_queryset(object_id=object_id) │
│ │
│ ConditionStepHandler._evaluate_and_route(..., wf_instance) │
│ → query.get_queryset(object_id=wf_instance.object_id) │
│ │
│ evaluate_condition_step() (Celery task) │
│ → delegates to evaluate_condition_for_document() │
│ → auto-extracts via the service function above │
├──────────────────────────────────────────────────────────────┤
│ UI Layer (QueryBuilderMixin) │
│ get_aggregate_options(model_config) │
│ → For each relationship, discovers numeric fields │
│ → Returns {function, field, related_model, label, alias} │
│ → Injected into filters_by_model as 'aggregates' key │
└──────────────────────────────────────────────────────────────┘
Data flow for object ID:
WorkflowInstance (has object_id + content_type)
│
├─→ evaluate_condition_for_document(document, query)
│ document.pk → str(object_id)
│ → query.get_queryset(object_id=object_id)
│
├─→ ConditionStepHandler._evaluate_and_route(..., wf_instance)
│ wf_instance.object_id
│ → query.get_queryset(object_id=wf_instance.object_id)
│
└─→ evaluate_condition_step() (Celery task)
→ evaluate_condition_for_document(wf_instance.document, ...)
→ auto-extracts via service function
Non-workflow callers (reports, admin query builder, tests):
→ query.get_queryset() — object_id defaults to None, no filtering applied
Data flow for aggregates:
UI sends or workflow loads SavedQuery with filters_json:
{
"condition": "AND",
"annotations": [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
],
"rules": [
{"id": "total_amount", "field": "total_amount", "type": "double", "operator": "greater", "value": "1000"}
]
}
→ SavedQuery.get_queryset()
→ parse_annotations(model_class, annotations)
→ validates 'lines' is a valid relationship on the model
→ returns {'total_amount': Sum('lines__amount')}
→ qs = model_class.objects.annotate(total_amount=Sum('lines__amount'))
→ qs.filter(total_amount__gt=1000)
→ Django pushes aggregate filter to HAVING clause automatically
Quick Start
Defining an Aggregate-Enabled Query
{
"condition": "AND",
"annotations": [
{"function": "sum", "field": "line_total", "related_model": "lines", "alias": "total"},
{"function": "count", "field": "id", "related_model": "lines", "alias": "item_count"}
],
"rules": [
{"id": "total", "field": "total", "type": "double", "operator": "greater", "value": "500"},
{"id": "item_count", "field": "item_count", "type": "integer", "operator": "greater", "value": "3"}
]
}
This query filters documents where the total of all line amounts exceeds 500 AND there are more than 3 line items.
Saving Through the API
POST to SaveQueryView:
{
"name": "Large Orders",
"model_slug": "frameworktestdocument",
"workflow_name": "InvoiceWorkflow",
"query": {
"condition": "AND",
"annotations": [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
],
"rules": [
{"id": "total_amount", "field": "total_amount", "type": "double", "operator": "greater", "value": "1000"}
]
}
}
Or send annotations as a separate top-level key — the view merges it into filters_json:
{
"name": "Large Orders",
"model_slug": "frameworktestdocument",
"workflow_name": "InvoiceWorkflow",
"query": {
"condition": "AND",
"rules": [...]
},
"annotations": [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
]
}
Executing with Object ID
GET /workflow/savedquery/5/?execute=1&object_id=550e8400-e29b-41d4-a716-446655440000
The view passes object_id to SavedQuery.execute(object_id=...), which filters results to only that document.
Using in the Workflow Engine
No configuration needed. When a condition step evaluates:
# Web path: step_handlers.py — has workflow_instance
condition_query.get_queryset(object_id=workflow_instance.object_id)
# Celery path: workflow_tasks.py — delegates to service function
evaluate_condition_for_document(workflow_instance.document, condition_query)
# → extracts document.pk → condition_query.get_queryset(object_id=document.pk)
Object ID Filtering
SavedQuery.get_queryset(object_id=None)
The get_queryset() method now accepts an optional object_id parameter. When provided, the resulting queryset is filtered to only the record with that primary key.
def get_queryset(self, object_id=None):
model_class = self.content_type.model_class()
if not model_class:
raise ValueError("Model not found")
# ... annotation logic ...
result_qs = qs.filter(q)
if object_id is not None:
result_qs = result_qs.filter(pk=object_id)
return result_qs
object_id Type
object_id is a string (UUID for EnezaModel subclasses). Django's ORM handles UUID strings natively via pk=..., so no manual type coercion is needed.
Which Methods Accept object_id
| Method | Signature | Notes |
|---|---|---|
SavedQuery.get_queryset() |
get_queryset(object_id=None) |
Primary method |
SavedQuery.execute() |
execute(object_id=None) |
Alias — forwards to get_queryset() |
SavedQuery.execute_new_format() |
execute_new_format(object_id=None) |
New format parser — also supports annotations |
Non-Workflow Callers
Callers outside the workflow engine (reports, admin query builder, tests) can omit object_id — it defaults to None and no filtering is applied.
# No object filtering — returns all matching records
results = saved_query.get_queryset()
# With object filtering — returns only matching record with that PK
results = saved_query.get_queryset(object_id="550e8400-e29b-41d4-a716-446655440000")
Aggregate Annotations
parse_annotations(model_class, annotations_list)
Location: enezaworkflowengine/utils.py
Converts an annotations JSON list into a dict of Django aggregate expressions, suitable for queryset.annotate(**result).
from enezaworkflowengine.utils import parse_annotations
annotations = [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"},
{"function": "count", "field": "id", "related_model": "lines", "alias": "line_count"},
{"function": "avg", "field": "price", "related_model": "items", "alias": "avg_price"},
{"function": "min", "field": "date", "related_model": "events", "alias": "first_event"},
{"function": "max", "field": "value", "related_model": "items", "alias": "max_value"},
]
result = parse_annotations(Invoice, annotations)
# {
# 'total_amount': Sum('lines__amount'),
# 'line_count': Count('lines__id'),
# 'avg_price': Avg('items__price'),
# 'first_event': Min('events__date'),
# 'max_value': Max('items__value'),
# }
queryset = Invoice.objects.annotate(**result)
Supported Aggregate Functions
| Function | Django Aggregate | Example Lookup |
|---|---|---|
sum |
Sum('related__field') |
Sum('lines__amount') |
count |
Count('related__field') |
Count('lines__id') |
avg |
Avg('related__field') |
Avg('items__price') |
min |
Min('related__field') |
Min('events__date') |
max |
Max('related__field') |
Max('items__value') |
Relationship Validation
Before building the aggregate expression, parse_annotations() validates that related_model is a valid relationship on the model class. It checks in order:
- Forward FK/M2M fields — checks
model_class._meta.get_field(related_model).is_relation - Reverse FK relations — checks
model_class._meta.related_objectsfor matching accessor name - Reverse M2M relations — checks
model_class._meta.many_to_manyfor matching accessor name
If the relationship is not found, a ValueError is raised with a descriptive message.
Using Annotations in Filter Rules
Once defined, annotation aliases can be referenced in filter rules just like regular model fields. Rules referencing annotation aliases must use the same alias value as both id and field:
{
"annotations": [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total_amount"}
],
"rules": [
{
"id": "total_amount",
"field": "total_amount",
"type": "double",
"operator": "greater",
"value": "1000"
}
]
}
Execution order is critical: Django applies annotate() before filter(), so aggregate aliases are available in WHERE/HAVING clauses. Django automatically pushes filterable annotations to HAVING rather than WHERE.
Backward Compatibility
- Queries without an
annotationskey work exactly as before — no aggregation is applied. - Old-format JSON (with only
rulesandcondition) continues to work unchanged. - The
execute_new_format()method also supports annotations.
JSON Format Reference
Full Format with Annotations (Rules-style)
{
"condition": "AND",
"annotations": [
{
"function": "sum | count | avg | min | max",
"field": "field_name_on_related_model",
"related_model": "relation_accessor_name",
"alias": "output_name_for_filter_rules"
}
],
"rules": [
{
"id": "field_or_alias",
"field": "field_or_alias",
"type": "string | integer | double | boolean | date | datetime",
"operator": "equal | not_equal | contains | greater | ...",
"value": "value_to_compare"
}
]
}
Full Format with Annotations (Groups-style)
{
"condition": "AND",
"primary_model": "frameworktestdocument",
"related_models": ["lines"],
"annotations": [
{
"function": "sum",
"field": "amount",
"related_model": "lines",
"alias": "total_amount"
}
],
"groups": [
{
"condition": "AND",
"rules": [
{
"id": "total_amount",
"field": "total_amount",
"type": "double",
"operator": "greater",
"value": "1000"
}
]
}
]
}
Annotation Entry Schema
| Key | Type | Required | Description |
|---|---|---|---|
function |
string |
Yes | Aggregate function: sum, count, avg, min, or max |
field |
string |
Yes | Field name on the related model to aggregate |
related_model |
string |
Yes | FK/M2M field name or reverse relation accessor on the primary model |
alias |
string |
Yes | Output name referenced in filter rules |
View Layer Integration
SaveQueryView (POST)
Accepts annotations either inside the query object or as a separate top-level annotations key:
# Inside the query (preferred):
{
"query": {
"condition": "AND",
"annotations": [...],
"rules": [...]
}
}
# As a separate key (also supported):
{
"query": {"condition": "AND", "rules": [...]},
"annotations": [...]
}
The view merges separate annotations into filters_json before saving:
annotations = data.get('annotations')
if annotations and isinstance(filters_json, dict):
filters_json['annotations'] = annotations
SaveQueryDetailView (GET with execute=1)
Accepts optional object_id query parameter:
GET /workflow/savedquery/5/?execute=1&object_id=<uuid>
Implementation:
object_id = self.request.GET.get('object_id')
queryset = self.object.execute(object_id=object_id)
BuildQueryView (Template Context)
The filters_by_model JSON now includes an aggregates key for each model:
filters_by_model = {
m['slug']: self.get_model_filters(m['app_label'], m['model_name'], model_config=m)
for m in allowed_models
}
for m in allowed_models:
filters_by_model[m['slug']]['aggregates'] = self.get_aggregate_options(m)
The template receives this via filters_by_model_json:
{
"frameworktestdocument": {
"direct": [/* field filters */],
"related": [/* related model filters */],
"aggregates": [
{
"function": "sum",
"field": "amount",
"related_model": "lines",
"label": "Sum of Lines Amount",
"alias": "lines_amount_sum"
}
]
}
}
Workflow Engine Auto-Extraction
evaluate_condition_for_document()
File: enezaworkflowengine/services/condition_step.py
Receives a resolved document model instance. Extracts its PK and passes it to get_queryset():
def evaluate_condition_for_document(document, condition_query):
if not document or not condition_query:
return False
try:
object_id = str(document.pk)
queryset = condition_query.get_queryset(object_id=object_id)
result_count = queryset.count()
return result_count > 0
except Exception as e:
raise ConditionStepError(f"Failed to evaluate condition: {str(e)}")
ConditionStepHandler._evaluate_and_route()
File: enezaworkflowengine/services/step_handlers.py:242
Has direct access to workflow_instance.object_id. Passes it directly — no DB query needed:
queryset = condition_query.get_queryset(object_id=workflow_instance.object_id)
result = queryset.count() > 0
evaluate_condition_step() (Celery Task)
File: enezaworkflowengine/tasks/workflow_tasks.py:254
Delegates to evaluate_condition_for_document(workflow_instance.document, condition_query). The service function auto-extracts the PK, so no change was needed in the Celery task.
Auto-Extraction Flow Diagram
WorkflowInstance (has object_id + content_type)
│
├─→ evaluate_condition_for_document(document, query)
│ document.pk → str(object_id)
│ → query.get_queryset(object_id=object_id)
│
├─→ ConditionStepHandler._evaluate_and_route(..., wf_instance)
│ wf_instance.object_id
│ → query.get_queryset(object_id=wf_instance.object_id)
│
└─→ evaluate_condition_step() (Celery task)
→ evaluate_condition_for_document(wf_instance.document, ...)
→ auto-extracts via service function above
Non-workflow callers (reports, admin query builder, tests):
→ query.get_queryset() — object_id defaults to None, no filtering applied
Aggregate Metadata API
QueryBuilderMixin.get_aggregate_options(model_config)
Location: enezaworkflowengine/views/mixin.py
Generates aggregate annotation options for the query builder UI. For each relationship in model_config['relationships'], it discovers numeric fields on the related model and returns aggregate function options.
def get_aggregate_options(self, model_config):
options = []
for rel_def in model_config.get('relationships', []):
rel_model = apps.get_model(rel_def['related_app'], rel_def['related_model'])
field_name = rel_def['field_name']
for field in rel_model._meta.get_fields():
if isinstance(field, (IntegerField, DecimalField, FloatField, ...)):
for func in ['sum', 'avg', 'min', 'max']:
options.append({
'function': func,
'field': field.name,
'related_model': field_name,
'label': f"{func.title()} of {rel_label} {field_verbose}",
'alias': f"{field_name}_{field.name}_{func}",
})
options.append({
'function': 'count',
'field': 'id',
'related_model': field_name,
'label': f"Count of {rel_label}",
'alias': f"{field_name}_count",
})
return options
Return Value Schema
Each returned option is a dict:
{
'function': 'sum', # Aggregate function name
'field': 'amount', # Field name on related model
'related_model': 'lines', # Relationship accessor on primary model
'label': 'Sum of Test Document Line Amount', # Human-readable UI label
'alias': 'lines_amount_sum', # Suggested alias for filter rule use
}
Numeric Field Types Discovered
The method detects these Django field types as numeric (aggregatable):
- IntegerField, BigIntegerField, SmallIntegerField
- PositiveIntegerField, PositiveSmallIntegerField
- DecimalField, FloatField
Count Option
A count option is always added for each relationship regardless of numeric fields, using field: 'id'.
Error Handling & Validation
parse_annotations() Validation Errors
| Condition | Error Message |
|---|---|
| Missing required keys | "Annotation {...} is missing required keys: 'function', 'field', 'related_model', 'alias'" |
| Unsupported function | "Unsupported aggregate function 'percentile'. Supported: sum, count, avg, min, max" |
| Invalid relationship | "'bad_relation' is not a valid relationship on Invoice. Check the related_model name against FK fields, M2M fields, or reverse relation accessors." |
Django-Level Errors
| Condition | Behavior |
|---|---|
object_id doesn't match model PK type |
Django raises ValueError — caller handles |
| Annotations with no filter rules | Still valid — returns annotated queryset |
Old format JSON (no annotations key) |
Backward compatible — no aggregation applied |
| Multiple annotations with same alias | Last one wins (dict overwrite) — user responsibility |
Workflow Engine Errors
Condition evaluation failures in the workflow engine are caught and handled by existing ConditionStepError / ConditionEvaluationError exceptions with appropriate fallback behavior (FAIL_OPEN / FAIL_CLOSED / FAIL_ESCALATE).
Complete Examples
Example 1: Simple Aggregate Condition (Old Rules Format)
{
"condition": "AND",
"annotations": [
{"function": "sum", "field": "total_amount", "related_model": "lines", "alias": "grand_total"}
],
"rules": [
{"id": "grand_total", "field": "grand_total", "type": "double", "operator": "greater", "value": "5000"}
]
}
SQL equivalent:
SELECT *, SUM(lines.total_amount) AS grand_total
FROM invoice
LEFT JOIN invoice_line AS lines ON lines.invoice_id = invoice.id
GROUP BY invoice.id
HAVING SUM(lines.total_amount) > 5000
Example 2: Multiple Aggregates with Combined Rules
{
"condition": "AND",
"annotations": [
{"function": "count", "field": "id", "related_model": "lines", "alias": "item_count"},
{"function": "avg", "field": "unit_price", "related_model": "lines", "alias": "avg_price"}
],
"rules": [
{"id": "item_count", "field": "item_count", "type": "integer", "operator": "greater_or_equal", "value": "5"},
{"id": "avg_price", "field": "avg_price", "type": "double", "operator": "less", "value": "100"}
]
}
Finds documents with at least 5 line items where the average unit price is below 100.
Example 3: Aggregate + Regular Field Mixed Conditions
{
"condition": "AND",
"annotations": [
{"function": "sum", "field": "amount", "related_model": "lines", "alias": "total"}
],
"rules": [
{"id": "status", "field": "status", "type": "string", "operator": "equal", "value": "open"},
{"id": "total", "field": "total", "type": "double", "operator": "greater", "value": "1000"}
]
}
Filters open documents where the sum of line amounts exceeds 1000. Both regular model fields and annotation aliases work together.
Example 4: Groups Format with Annotations
{
"condition": "AND",
"primary_model": "frameworktestdocument",
"related_models": ["lines"],
"annotations": [
{"function": "max", "field": "amount", "related_model": "lines", "alias": "max_line_amount"},
{"function": "min", "field": "amount", "related_model": "lines", "alias": "min_line_amount"}
],
"groups": [
{
"condition": "AND",
"rules": [
{"id": "max_line_amount", "field": "max_line_amount", "type": "double", "operator": "greater", "value": "500"},
{"id": "min_line_amount", "field": "min_line_amount", "type": "double", "operator": "less", "value": "50"}
]
}
]
}
Finds documents where the highest line amount exceeds 500 AND the lowest line amount is under 50.
Example 5: Programmatic Usage
from enezaworkflowengine.models import SavedQuery
from enezaworkflowengine.utils import parse_annotations
# Create a query programmatically
query = SavedQuery.objects.create(
name="High Value Orders",
content_type=invoice_ct,
filters_json={
"condition": "AND",
"annotations": [
{"function": "sum", "field": "line_total", "related_model": "lines", "alias": "total"},
{"function": "count", "field": "id", "related_model": "lines", "alias": "line_count"},
],
"rules": [
{"id": "total", "field": "total", "type": "double", "operator": "greater", "value": "10000"},
{"id": "line_count", "field": "line_count", "type": "integer", "operator": "greater", "value": "10"},
],
},
user=user,
workflow="InvoiceWorkflow",
)
# Execute with object ID filtering
results = query.get_queryset(object_id="550e8400-e29b-41d4-a716-446655440000")
# Execute without object ID (all records)
all_results = query.get_queryset()
Example 6: Using Directly with parse_annotations
from django.db.models import Sum, Count
from enezaworkflowengine.utils import parse_annotations
from myapp.models import Invoice
annotations = parse_annotations(Invoice, [
{"function": "sum", "field": "line_total", "related_model": "lines", "alias": "total"},
{"function": "count", "field": "id", "related_model": "lines", "alias": "line_count"},
])
queryset = Invoice.objects.annotate(**annotations)
queryset = queryset.filter(total__gt=1000, line_count__gte=5)
Reference
Files Modified
| File | Changes | Purpose |
|---|---|---|
enezaworkflowengine/utils.py |
Added AGGREGATE_FUNCTION_MAP, parse_annotations(), _validate_relationship() |
Converts annotation JSON to Django aggregates with validation |
enezaworkflowengine/models/querybuilder.py |
Updated get_queryset(object_id=None), execute(object_id=None), execute_new_format(object_id=None) |
Object ID filtering + annotation integration |
enezaworkflowengine/views/mixin.py |
Added get_aggregate_options() |
Generates aggregate metadata for query builder UI |
enezaworkflowengine/views/querybuilder.py |
Updated BuildQueryView, SaveQueryView.post(), SaveQueryDetailView |
Accepts annotations in views + object_id param |
enezaworkflowengine/services/condition_step.py |
Updated evaluate_condition_for_document() |
Auto-extracts document.pk → passes to get_queryset() |
enezaworkflowengine/services/step_handlers.py |
Updated ConditionStepHandler._evaluate_and_route() |
Auto-extracts workflow_instance.object_id → passes to get_queryset() |
SavedQuery API
| Method | Signature | Notes |
|---|---|---|
get_queryset() |
get_queryset(object_id=None) |
Core method — applies annotations + filters + object_id |
execute() |
execute(object_id=None) |
Alias for get_queryset() |
execute_new_format() |
execute_new_format(object_id=None) |
New format parser — also supports annotations |
parse_annotations() API
parse_annotations(model_class, annotations_list) → Dict[str, Aggregate]
| Parameter | Type | Description |
|---|---|---|
model_class |
Model |
Django model class — used to validate relationships |
annotations_list |
List[Dict] |
List of annotation dicts with function, field, related_model, alias |
| Returns | Dict[str, Aggregate] |
Dict suitable for queryset.annotate(**result) |
Annotation Entry Schema
| Key | Required | Values | Description |
|---|---|---|---|
function |
Yes | sum, count, avg, min, max |
Aggregate function |
field |
Yes | Any field name on the related model | Field to aggregate |
related_model |
Yes | FK/M2M field name or reverse relation accessor | Relationship on primary model |
alias |
Yes | Any string (snake_case recommended) | Name used in filter rules |
get_aggregate_options() API
QueryBuilderMixin.get_aggregate_options(model_config) → List[Dict]
| Parameter | Type | Description |
|---|---|---|
model_config |
Dict |
Model config dict from get_allowed_models() (must have relationships key) |
| Returns | List[Dict] |
List of aggregate option dicts with function, field, related_model, label, alias |
View Query Parameters
| View | Parameter | Description |
|---|---|---|
SaveQueryDetailView |
?execute=1 |
Trigger query execution |
SaveQueryDetailView |
?object_id=<uuid> |
Filter results to a single document |
BuildQueryView |
?workflow_name=X |
Load allowed models for a workflow |
Edge Cases
| Scenario | Behavior |
|---|---|
object_id is None |
No filtering applied (backward compatible) |
annotations key missing from filters_json |
No aggregation applied (backward compatible) |
annotations is empty list [] |
No aggregation applied |
Annotation references non-existent related_model |
ValueError raised with clear message |
| Annotation references unsupported function | ValueError raised listing supported functions |
| Multiple annotations with same alias | Last one wins (dict overwrite) |
| Annotation alias referenced in rules but not defined | Django raises FieldError |
object_id type mismatch with model PK |
Django raises ValueError |
Out of Scope
- Aggregate values in query result output
- Nested aggregates (aggregate over aggregate)
- Custom/expression-based annotations beyond the 5 standard functions
- UI/template changes for the query builder (handled separately as a frontend task)