Metadata-Version: 2.4
Name: django-flex
Version: 26.1.5
Summary: A flexible query language for Django - enable frontends to dynamically construct database queries
Author: Nehemiah Jacob
Maintainer: Nehemiah Jacob
License: MIT
Project-URL: Homepage, https://github.com/your-org/django-flex
Project-URL: Documentation, https://github.com/your-org/django-flex#readme
Project-URL: Repository, https://github.com/your-org/django-flex.git
Project-URL: Issues, https://github.com/your-org/django-flex/issues
Project-URL: Changelog, https://github.com/your-org/django-flex/blob/main/CHANGELOG.md
Keywords: django,query,api,flexible,dynamic,graphql-alternative,rest,orm
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 3.2
Classifier: Framework :: Django :: 4.0
Classifier: Framework :: Django :: 4.1
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django>=3.2
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-django>=4.5; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: black>=23.0; extra == "dev"
Requires-Dist: isort>=5.12; extra == "dev"
Requires-Dist: flake8>=6.0; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: django-stubs>=4.0; extra == "dev"
Dynamic: license-file

# Django-Flex

<p align="center">
    <em>A flexible query language for Django — let your frontend dynamically construct database queries</em>
</p>

<p align="center">
    <a href="https://pypi.org/project/django-flex/">
        <img src="https://img.shields.io/pypi/v/django-flex.svg" alt="PyPI version">
    </a>
    <a href="https://pypi.org/project/django-flex/">
        <img src="https://img.shields.io/pypi/pyversions/django-flex.svg" alt="Python versions">
    </a>
    <a href="https://github.com/your-org/django-flex/blob/main/LICENSE">
        <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
    </a>
</p>

---

**Django-Flex** enables frontends to send flexible, dynamic queries to your Django backend — think of it as a simpler alternative to GraphQL that feels native to Django.

## Features

- **Field Selection** — Request only the fields you need, including nested relations
- **JSONField Support** — Seamless dot notation for nested JSON data
- **Dynamic Filtering** — Full Django ORM operator support with composable AND/OR/NOT
- **Smart Pagination** — Limit/offset with cursor-based continuation
- **Built-in Security** — Row-level, field-level, and operation-level permissions
- **Automatic Optimization** — N+1 prevention with smart `select_related`
- **Django-Native** — Feels like a natural extension of Django

## Installation

```bash
pip install django-flex
```

Add to your Django settings:

```python
# settings.py
INSTALLED_APPS = [
    ...
    'django_flex',
]

# Optional: Configure permissions and defaults
DJANGO_FLEX = {
    'DEFAULT_LIMIT': 50,
    'MAX_LIMIT': 200,
    'ALWAYS_HTTP_200': False,  # When True, all responses return HTTP 200
    'EXPOSE': {
        # See Permission Configuration below
    },
}
```

### Response Modes

By default, django-flex uses standard HTTP status codes (200, 400, 404, etc.).

Set `ALWAYS_HTTP_200 = True` to always return HTTP 200 with the status code in the payload:

```python
# settings.py
DJANGO_FLEX = {
    'ALWAYS_HTTP_200': True,  # All responses return HTTP 200
}
```

**When `ALWAYS_HTTP_200 = True`:**
```json
// HTTP 200 (always)
{"status_code": 404, "error": "Object not found"}
{"status_code": 200, "id": 1, "name": "Test"}
```

**When `ALWAYS_HTTP_200 = False` (default):**
```json
// HTTP 404
{"error": "Object not found"}

// HTTP 200
{"id": 1, "name": "Test"}
```

## Quick Start

### 1. Class-Based View (Recommended)

```python
# views.py
from django_flex import FlexQueryView
from myapp.models import Booking

class BookingQueryView(FlexQueryView):
    model = Booking
    
    # Define permissions for this view
    flex_permissions = {
        'authenticated': {
            'rows': lambda user: Q(team__members=user),
            'fields': ['id', 'status', 'customer.name', 'customer.email'],
            'filters': ['status', 'status.in', 'customer.name.icontains'],
            'order_by': ['created_at', '-created_at'],
            'ops': ['get', 'query'],
        },
    }
```

```python
# urls.py
from django.urls import path
from myapp.views import BookingQueryView

urlpatterns = [
    path('api/bookings/', BookingQueryView.as_view()),
    path('api/bookings/<int:pk>/', BookingQueryView.as_view()),  # Single object by ID
]
```

### 2. Make Queries from Frontend

```javascript
// List bookings with field selection and filtering (JSON body)
const response = await fetch('/api/bookings/', {
    method: 'GET',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
        fields: 'id, status, customer.name, customer.email',
        filters: {
            'status.in': ['confirmed', 'completed'],
            'customer.name.icontains': 'khan'
        },
        order_by: '-created_at',
        limit: 20
    })
});

const data = await response.json();
// {
//     "pagination": {"offset": 0, "limit": 20, "has_more": true},
//     "results": {
//         "1": {"id": 1, "status": "confirmed", "customer": {"name": "Aisha Khan", "email": "aisha@example.com"}},
//         "2": {"id": 2, "status": "completed", "customer": {"name": "Omar Khan", "email": "omar@example.com"}}
//     }
// }
```

### 3. Query Params (Alternative)

Query params can be used instead of JSON body. Query params **override** body params.

```
GET /api/bookings/?fields=id,status,customer.name&filters.status=confirmed&filters.customer.name.icontains=khan&order_by=-created_at&limit=20
```

Equivalent to:
```javascript
{
    fields: 'id, status, customer.name',
    filters: { status: 'confirmed', 'customer.name.icontains': 'khan' },
    order_by: '-created_at',
    limit: 20
}
```

| Query Param | Body Equivalent |
|-------------|-----------------|
| `fields=id,name` | `{fields: 'id, name'}` |
| `limit=20` | `{limit: 20}` |
| `offset=10` | `{offset: 10}` |
| `order_by=-created_at` | `{order_by: '-created_at'}` |
| `filters.status=pending` | `{filters: {status: 'pending'}}` |
| `filters.name.icontains=khan` | `{filters: {'name.icontains': 'khan'}}` |

```javascript
// Get single object by ID (using URL)
const booking = await fetch('/api/bookings/1/', {
    method: 'GET',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
        fields: 'id, status, customer.*, address.*'
    })
});
// Returns: {"id": 1, "status": "confirmed", "customer": {...}, "address": {...}}
```

## Query Language Reference

### Field Selection

```javascript
// All fields on the model
{ fields: '*' }

// Specific fields
{ fields: 'id, name, email' }

// Nested relation fields (dot notation)
{ fields: 'id, customer.name, customer.email' }

// Relation wildcards
{ fields: 'id, status, customer.*, address.*' }
```

### JSONField Support

Django-Flex seamlessly supports `JSONField` — the frontend uses the same dot notation without knowing the difference between relations and JSON keys:

```javascript
// Assume Customer model has: metadata = JSONField(default=dict)
// metadata = {"settings": {"theme": "dark", "lang": "en"}, "tags": ["vip"]}

// Select nested JSON values (same syntax as relations)
{ fields: 'name, metadata.settings.theme, metadata.tags' }
// Returns: {"name": "Alice", "metadata": {"settings": {"theme": "dark"}, "tags": ["vip"]}}

// Select entire JSONField
{ fields: 'name, metadata' }
// Returns: {"name": "Alice", "metadata": {"settings": {...}, "tags": [...]}}

// Filter on nested JSON values
{ filters: { 'metadata.settings.theme': 'dark' } }

// With operators (all Django operators work)
{ 
    filters: { 
        'metadata.level.gte': 5,
        'metadata.tags.icontains': 'vip' 
    } 
}
```

**How it works:** Dot notation is transparently converted to Django's double-underscore format, which works for both ForeignKey relations AND JSONField nested keys.

**Permissions:** JSONField paths work with the same permission patterns:

```python
DJANGO_FLEX = {
    'EXPOSE': {
        'customer': {
            'staff': {
                'fields': [
                    'name',
                    'metadata',              # Entire JSONField
                    'metadata.settings',     # Specific key
                    'metadata.settings.*',   # All keys under settings (any depth)
                ],
                'filters': [
                    'metadata.settings.theme',
                    'metadata.level',
                ],
            }
        }
    }
}
```

### Filtering

```javascript
// Simple equality
{ filters: { status: 'confirmed' } }

// With operators
{ filters: { 'price.gte': 100, 'price.lte': 500 } }

// Text search
{ filters: { 'name.icontains': 'khan' } }

// List membership
{ filters: { 'status.in': ['pending', 'confirmed', 'completed'] } }

// OR conditions
{ filters: { or: { status: 'pending', 'customer.vip': true } } }

// NOT conditions  
{ filters: { 
    not: { 
        status: 'cancelled' 
        } 
    } 
}

// Complex composition
{
    filters: {
        'created_at.gte': '2024-01-01',
        or: {
            status: 'confirmed' ,
            and: { 
                status: 'pending', 
                urgent: true 
            } 
        }
    }
}
```

**Supported Operators:**

| Category | Operators |
|----------|-----------|
| Comparison | `lt`, `lte`, `gt`, `gte`, `exact`, `iexact`, `in`, `isnull`, `range` |
| Text | `contains`, `icontains`, `startswith`, `istartswith`, `endswith`, `iendswith`, `regex`, `iregex` |
| Date/Time | `date`, `year`, `month`, `day`, `week_day`, `hour`, `minute`, `second` |

### Pagination

```javascript
{
    limit: 20,      // Number of results (default: 50, max: 200)
    offset: 0,      // Starting position
    order_by: '-created_at'  // Sort order (prefix with - for descending)
}
```

Response includes pagination info:

```javascript
{
    "pagination": {
        "offset": 0,
        "limit": 20,
        "has_more": true,
        "next": {
            "fields": "...",
            "filters": {...},
            "limit": 20,
            "offset": 20
        }
    }
}
```

## Permission Configuration

Django-Flex uses a **strict deny-by-default** security model. Nothing is allowed unless explicitly granted.

### Quick Reference

| Config Value | Meaning |
|--------------|---------|
| `"*"` | **Allow all** (full access) |
| `{}`, `[]`, `""`, `None` | **Deny all** (no access) |

### The `"*"` Shorthand

Use `"*"` to grant full access to a role:

```python
DJANGO_FLEX = {
    'EXPOSE': {
        'user': {
            'superuser': '*',  # Full access to everything
        },
        'booking': {
            'admin': '*',      # Full access to bookings
            'staff': {...},    # Explicit permissions
        },
    },
}
```

The `"*"` shorthand expands to:
```python
{
    'rows': '*',      # All rows (no filter)
    'fields': ['*'],  # All fields including nested
    'filters': '*',   # All filters
    'order_by': '*',  # All order_by
    'ops': ['get', 'query', 'create', 'update', 'delete'],
}
```

### Explicit Permissions

```python
DJANGO_FLEX = {
    'EXPOSE': {
        'booking': {
            # Fields excluded from wildcard expansion
            'exclude': ['internal_notes', 'stripe_payment_id'],
            
            'owner': {
                # Row-level: which rows can this role see?
                'rows': lambda user: Q(created_by=user),
                
                # Field-level: which fields can they access?
                # "*" matches ALL fields including nested
                'fields': ['*'],
                
                # Filter-level: EACH filter+operator must be listed
                'filters': [
                    'status',              # Only exact: status=X
                    'status.in',           # status.in=[A,B]
                    'status.icontains',    # status.icontains=X
                    'created_at.gte',      # created_at.gte=DATE
                    'created_at.lte',      # created_at.lte=DATE
                ],
                
                # Order-level: which fields can they sort by?
                'order_by': ['created_at', '-created_at'],
                
                # Operation-level: which actions?
                'ops': ['get', 'query'],
            },
            
            # Empty config = NO ACCESS
            'viewer': {},
            
            # Roles not listed = NO ACCESS
        },
    },
}
```

> **Important**: Filters require explicit operator grants. `'status'` does NOT auto-allow `'status.in'` or `'status.gte'`. Each must be listed separately.

### Custom Role Resolution

Django-Flex uses Django's built-in groups for role resolution:

```python
from django_flex import FlexPermission

class MyPermission(FlexPermission):
    def get_user_role(self, user):
        if user.is_superuser:
            return 'superuser'
        if user.groups.filter(name='Managers').exists():
            return 'manager'
        return 'staff'
```

## Usage Patterns

### 1. Class-Based View (Recommended)

```python
from django_flex import FlexQueryView

class BookingQueryView(FlexQueryView):
    model = Booking
    require_auth = True
    allowed_actions = ['get', 'query']
    flex_permissions = {...}
```

### 2. Function Decorator

```python
from django_flex import flex_query
from django.http import JsonResponse

@flex_query(
    model=Booking,
    allowed_fields=['id', 'status', 'customer.name'],
    allowed_filters=['status', 'status.in'],
)
def booking_list(request, result, query_spec):
    return JsonResponse(result.to_dict())
```

### 3. Programmatic Usage

```python
from django_flex import FlexQuery

def my_view(request):
    result = FlexQuery(Booking).execute({
        'fields': 'id, customer.name',
        'filters': {'status': 'confirmed'},
        'limit': 20,
    }, user=request.user)
    
    return JsonResponse(result.to_dict())
```

### 4. Middleware (Single Endpoint)

```python
# settings.py
MIDDLEWARE = [
    ...
    'django_flex.middleware.FlexQueryMiddleware',
]

DJANGO_FLEX = {
    'MIDDLEWARE_PATH': '/api/',
    ...
}
```

The middleware supports **two styles** of API access:

#### Style A: JSON Body (Single Endpoint)

All requests go to `/api/` with model and action in the body:

```javascript
fetch('/api/', {
    method: 'POST',
    body: JSON.stringify({
        _model: 'booking',
        _action: 'query',
        fields: 'id, status',
        limit: 20
    })
});
```

#### Style B: RESTful URLs (Recommended)

Use standard REST patterns with HTTP method mapping:

```bash
# Query all bookings (GET → query action)
curl http://localhost:8000/api/bookings/

# Get single booking by ID (GET with ID → get action)
curl http://localhost:8000/api/bookings/1

# Create booking (POST → create action)
curl -X POST http://localhost:8000/api/bookings/ \
  -H "Content-Type: application/json" \
  -d '{"customer_id": 1, "status": "pending"}'

# Update booking (PUT/PATCH → update action)
curl -X PUT http://localhost:8000/api/bookings/1 \
  -H "Content-Type: application/json" \
  -d '{"status": "confirmed"}'

# Delete booking (DELETE → delete action)
curl -X DELETE http://localhost:8000/api/bookings/1
```

**HTTP Method Mapping:**

| Method | URL Pattern | Action |
|--------|-------------|--------|
| GET | `/api/{model}/` | `query` |
| GET | `/api/{model}/{id}` | `get` |
| POST | `/api/{model}/` | `create` |
| PUT/PATCH | `/api/{model}/{id}` | `update` |
| DELETE | `/api/{model}/{id}` | `delete` |

You can still pass query options in the body for RESTful requests:

```javascript
// GET /api/bookings/ with body for filtering
fetch('/api/bookings/', {
    method: 'GET',
    body: JSON.stringify({
        fields: 'id, status, customer.name',
        filters: { status: 'confirmed' },
        limit: 20
    })
});
```

## Configuration Reference

```python
DJANGO_FLEX = {
    # Pagination
    'DEFAULT_LIMIT': 50,        # Default page size
    'MAX_LIMIT': 200,           # Maximum page size (hard cap)
    
    # Security
    'MAX_RELATION_DEPTH': 2,    # Max depth for nested fields/filters
    'REQUIRE_AUTHENTICATION': True,  # Require auth by default
    'AUDIT_QUERIES': False,     # Log all queries (for debugging)
    
    # Middleware
    'MIDDLEWARE_PATH': '/api/',  # Path for middleware endpoint
    
    # Optional: versioned APIs with independent settings
    'VERSIONS': {
        'v1': {'path': '/api/v1/', 'EXPOSE': {...}},
        'v2': {'path': '/api/v2/', 'EXPOSE': {...}},
    },
    
    # Model permissions (see Rate Limiting section below)
    'EXPOSE': {...},
}
```

### API Versioning

Run unversioned `/api/` alongside versioned `/api/v1/`, `/api/v2/` with different settings per version:

```python
DJANGO_FLEX = {
    'MIDDLEWARE_PATH': '/api/',  # Unversioned endpoint
    'EXPOSE': {...},        # Top-level = unversioned settings
    'MAX_LIMIT': 200,
    
    'VERSIONS': {
        'v1': {
            'path': '/api/v1/',
            'EXPOSE': {...},  # v1-specific permissions
            'MAX_LIMIT': 100,      # v1-specific limit
        },
        'v2': {
            'path': '/api/v2/',
            'EXPOSE': {...},  # v2-specific permissions
            'MAX_LIMIT': 200,
        },
    },
}
```

## Rate Limiting

Rate limits can be configured at multiple levels (most specific wins):

```python
DJANGO_FLEX = {
    'EXPOSE': {
        'booking': {
            # Model-level: integer = same for all ops
            'rate_limit': 60,
            
            # OR dict for per-operation limits
            # 'rate_limit': {'default': 60, 'query': 30, 'get': 120},
            
            # Anonymous users - very restricted
            'anon': {
                'fields': ['id', 'status'],
                'ops': ['query'],
                'rate_limit': 5,  # Only 5 requests/minute for anon
            },
            
            'authenticated': {
                'fields': ['*'],
                'ops': ['get', 'query'],
                'rate_limit': 50,
            },
            
            'staff': {
                'fields': ['*'],
                'ops': ['get', 'query'],
                'rate_limit': 200,  # Staff gets higher limits
            },
        },
    },
}
```

When rate limit is exceeded, returns HTTP 429 with `Retry-After` header:

```json
{"error": "Rate limit exceeded", "retry_after": 45}
```

## Response Format

Responses use HTTP status codes (200, 400, 401, 403, 404) to indicate success/failure.

### Successful Single Object (get) - HTTP 200

```json
{
    "id": 1,
    "status": "confirmed",
    "customer": {
        "name": "Aisha Khan",
        "email": "aisha@example.com"
    }
}
```

### Successful Query (query) - HTTP 200

```json
{
    "pagination": {
        "offset": 0,
        "limit": 20,
        "has_more": true,
        "next": {...}
    },
    "results": {
        "1": {...},
        "2": {...}
    }
}
```

### Error Response - HTTP 400/401/403/404

```json
{
    "error": "Access denied: field 'secret_field' not accessible"
}
```

## Why Django-Flex?

| Feature | Django-Flex | GraphQL | REST |
|---------|-------------|---------|------|
| Learning curve | Low (Django-native) | High | Low |
| Field selection | ✅ | ✅ | ❌ (fixed endpoints) |
| Dynamic filtering | ✅ | ✅ | Limited |
| Built-in security | ✅ | Manual | Manual |
| Django integration | Native | Requires graphene | Native |
| Schema definition | Optional | Required | N/A |
| N+1 prevention | Automatic | Manual | Manual |

## Contributing

Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## License

MIT License — see [LICENSE](LICENSE) for details.
