Rename djarea to mizan and fix React casing conventions
Rename the package from djarea to mizan across the entire codebase — Python package, React library, generators, tests, and examples. Fix JSX/hook casing (MizanProvider, useMizan, etc.) that broke when the original PascalCase names were lowercased during the rename. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install djarea from local source with channels support
|
||||
# Install mizan from local source with channels support
|
||||
COPY django/ /app/django/
|
||||
RUN pip install --no-cache-dir /app/django[channels] daphne
|
||||
|
||||
|
||||
2
MIZAN.md
2
MIZAN.md
@@ -6,7 +6,7 @@ This plan was written by Ryth's Claude.ai session after an extended design conve
|
||||
reviewing the full codebase, the original @compose discussion from January 2025, and
|
||||
several rounds of architectural refinement. Treat this as the spec.
|
||||
|
||||
The framework formerly called Djarea is now called **MIZAN**. Package names, imports,
|
||||
The framework formerly called mizan is now called **MIZAN**. Package names, imports,
|
||||
and references should be updated accordingly. The internal codegen engine is called
|
||||
**Maison** — it lives inside Mizan and does not need its own public surface.
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -20,7 +20,7 @@ test-react:
|
||||
|
||||
test-integration: docker-up
|
||||
@echo "Waiting for backend..."
|
||||
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/djarea/session/ > /dev/null 2>&1; do sleep 1; done'
|
||||
@timeout 30 sh -c 'until curl -sf http://localhost:8000/api/mizan/session/ > /dev/null 2>&1; do sleep 1; done'
|
||||
cd react && npm run test:integration
|
||||
@$(MAKE) docker-down
|
||||
|
||||
@@ -41,6 +41,6 @@ test-all: test test-integration
|
||||
|
||||
clean:
|
||||
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
||||
rm -rf django/src/djarea.egg-info django/dist django/build
|
||||
rm -rf django/src/mizan.egg-info django/dist django/build
|
||||
rm -rf react/dist react/node_modules
|
||||
rm -f example/db.sqlite3
|
||||
|
||||
335
README.md
335
README.md
@@ -1,94 +1,84 @@
|
||||
# DJAREA
|
||||
# mizan
|
||||
|
||||
A modern Django + React Framework for perfectionists with deadlines.
|
||||
Django + React server functions framework. RPC, not REST.
|
||||
|
||||
Write a Pydantic function, add the @client decorator, use configurable **Shape** types for your models.
|
||||
|
||||
Djarea generates the entire React client: all your type interfaces, function call hooks, autoatic JWT, and a simple `<DjangoContext/>` to make it all work.
|
||||
|
||||
No API routing, no serializers, no REST/CRUD bullshit.
|
||||
You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
|
||||
|
||||
```python
|
||||
@client
|
||||
def current_user(request) -> UserShape:
|
||||
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0]
|
||||
# Django
|
||||
@client(context='global')
|
||||
def current_user(request) -> UserOutput:
|
||||
return UserOutput(email=request.user.email)
|
||||
```
|
||||
|
||||
|
||||
```tsx
|
||||
const user: UserShape = useCurrentUser() // typed, cached, SSR-hydrated
|
||||
// React (generated)
|
||||
const user = useCurrentUser() // typed, SSR-hydrated, auto-refreshed
|
||||
```
|
||||
|
||||
The **Function** is the API contract. The **Shape** is the query. The hook is the artifact. That's it.
|
||||
## Packages
|
||||
|
||||
Starts with session auth and upgrades to JWT on login. **It just works**.
|
||||
| Package | Path | Install |
|
||||
|---------|------|---------|
|
||||
| `mizan` (Python) | `django/` | `uv add "mizan[channels] @ git+..."` |
|
||||
| `@rythazhur/mizan` (TypeScript) | `react/` | `npm install @rythazhur/mizan@git+...` |
|
||||
|
||||
## What Djarea does
|
||||
|
||||
A `@client` function in Django becomes a callable hook in React. The function's type signature orchestrates the entire pipeline for you — input validation, output serialization, TypeScript interfaces, and SQL projection.
|
||||
|
||||
```python
|
||||
class ArticleShape(Shape[Article]):
|
||||
id: int | None = None
|
||||
title: str
|
||||
author: FlatAuthorShape
|
||||
tags: list[TagShape] = []
|
||||
```
|
||||
|
||||
One Djarea **Shape** does three things simultaneously:
|
||||
- Defines the Pydantic model for validation and serialization
|
||||
- Generates a django-readers spec for a lean, field-scoped SQL query
|
||||
- Produces the TypeScript interface on the React side
|
||||
|
||||
Shapes are your codebase's **single source of truth** for backend/frontend data transfer.
|
||||
|
||||
## Quick start
|
||||
## Quick Start
|
||||
|
||||
### 1. Django setup
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS = [
|
||||
"djarea",
|
||||
"mizan",
|
||||
"myapp",
|
||||
]
|
||||
|
||||
# urls.py
|
||||
from django.urls import include, path
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
|
||||
# asgi.py (for WebSocket support)
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
from django.core.asgi import get_asgi_application
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
|
||||
### 2. Define your client functions
|
||||
### 2. Define server functions
|
||||
|
||||
```python
|
||||
# myapp/clients.py
|
||||
from djarea.client import client
|
||||
from djarea.shapes import Shape
|
||||
# myapp/mizan_clients.py
|
||||
from django.http import HttpRequest
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput:
|
||||
def echo(request: HttpRequest, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=text)
|
||||
|
||||
register(echo, "echo")
|
||||
```
|
||||
|
||||
Functions in `clients.py` are discovered automatically — same convention as `models.py`.
|
||||
### 3. Register in apps.py
|
||||
|
||||
### 3. Generate TypeScript
|
||||
```python
|
||||
class MyAppConfig(AppConfig):
|
||||
name = "myapp"
|
||||
|
||||
To get your generated React client, set this up in your frontend root:
|
||||
def ready(self):
|
||||
import myapp.mizan_clients # noqa: F401
|
||||
```
|
||||
|
||||
```javascript
|
||||
// django.config.mjs
|
||||
### 4. Generate TypeScript
|
||||
|
||||
```bash
|
||||
# django.config.mjs
|
||||
export default {
|
||||
source: {
|
||||
django: {
|
||||
@@ -100,23 +90,27 @@ export default {
|
||||
}
|
||||
```
|
||||
|
||||
Run this command everytime your client needs updating. You can also throw this it on a file watcher pointed at your backend code:
|
||||
|
||||
```bash
|
||||
npx djarea-generate
|
||||
npx mizan-generate
|
||||
```
|
||||
|
||||
### 4. Use in React
|
||||
This produces typed hooks, a typed provider, form hooks with Zod validation, and channel hooks.
|
||||
|
||||
### 5. Use in React
|
||||
|
||||
```tsx
|
||||
import { DjangoContext, useEcho, useCurrentUser, DjangoError } from '@/api'
|
||||
// layout.tsx
|
||||
import { DjangoContext } from '@/api'
|
||||
|
||||
// layout.tsx — one provider, handles everything
|
||||
export default function Layout({ children }) {
|
||||
return <DjangoContext>{children}</DjangoContext>
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// page.tsx
|
||||
import { useEcho, useCurrentUser, DjangoError } from '@/api'
|
||||
|
||||
function MyComponent() {
|
||||
const user = useCurrentUser()
|
||||
const echo = useEcho()
|
||||
@@ -127,80 +121,91 @@ function MyComponent() {
|
||||
console.log(result.message) // typed
|
||||
} catch (e) {
|
||||
if (e instanceof DjangoError) {
|
||||
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
||||
e.getFieldErrors('email') // field-level errors
|
||||
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shapes
|
||||
## Features
|
||||
|
||||
Shapes are Djarea's data protocol. A Shape defines exactly which fields to select from the database, validated through Pydantic and projected through django-readers. Different views get different Shapes — same model, different queries.
|
||||
| Backend | Frontend (generated) | Transport |
|
||||
|---------|---------------------|-----------|
|
||||
| `@client` | `useXxx()` | HTTP |
|
||||
| `@client(context='global')` | `useXxx()` + SSR hydration | HTTP |
|
||||
| `@client(context='local')` | `useXxx()` with params | HTTP |
|
||||
| `@client(websocket=True)` | `useXxx()` | WebSocket RPC |
|
||||
| `@client(auth=True\|'staff'\|callable)` | Auth errors as `DjangoError` | HTTP |
|
||||
| `mizanFormMixin` | `useXxxForm()` + Zod validation | HTTP |
|
||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||
| `@compose(...)` | Combined providers | varies |
|
||||
|
||||
```python
|
||||
# Full detail page — joins books with chapters
|
||||
class AuthorDetailShape(Shape[Author]):
|
||||
id: int | None = None
|
||||
name: str
|
||||
bio: str
|
||||
books: list[BookShape] = []
|
||||
## Architecture
|
||||
|
||||
# Dropdown menu — two columns, no joins
|
||||
class FlatAuthorShape(Shape[Author]):
|
||||
id: int | None = None
|
||||
name: str
|
||||
```
|
||||
React app
|
||||
└─ <DjangoContext> ← generated provider (includes ChannelProvider)
|
||||
├─ useCurrentUser() ← generated context hook (SSR-hydrated)
|
||||
├─ useEcho() ← generated function hook
|
||||
├─ useContactForm() ← generated form hook (Zod + server validation)
|
||||
└─ useChatChannel() ← generated channel hook (WebSocket)
|
||||
│
|
||||
├─ HTTP: POST /api/mizan/call/ { fn: "echo", args: { text: "hi" } }
|
||||
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
||||
│
|
||||
Django executor
|
||||
├─ Pydantic input validation
|
||||
├─ Auth check (session, JWT, or custom)
|
||||
├─ Function execution
|
||||
└─ Pydantic output serialization
|
||||
```
|
||||
|
||||
```python
|
||||
# Detail page: SELECT id, name, bio + prefetch books
|
||||
authors = AuthorDetailShape.query()
|
||||
The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
||||
|
||||
# Dropdown: SELECT id, name. That's it.
|
||||
authors = FlatAuthorShape.query()
|
||||
## Code Generation
|
||||
|
||||
`npx mizan-generate` reads Django schemas (no running server needed) and produces:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `generated.mizan.ts` | Pydantic model types (via openapi-typescript) |
|
||||
| `generated.django.tsx` | `DjangoContext` provider + all typed hooks |
|
||||
| `generated.django.server.ts` | SSR hydration helper (`getDjangoHydration`) |
|
||||
| `generated.forms.ts` | Form hooks with Zod schemas (`useContactForm`, etc.) |
|
||||
| `generated.channels.ts` | Channel message types |
|
||||
| `generated.channels.hooks.tsx` | Channel hooks (`useChatChannel`, etc.) |
|
||||
| `index.ts` | Consolidated re-exports |
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors from server functions are thrown as `DjangoError`:
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await echo({ text: 'hello' })
|
||||
} catch (e) {
|
||||
if (e instanceof DjangoError) {
|
||||
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | ...
|
||||
e.message // Human-readable message
|
||||
e.details // Field-level validation errors, etc.
|
||||
e.isAuthError()
|
||||
e.isValidationError()
|
||||
e.getFieldErrors('email')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Shapes also support diffing. When the frontend sends state back, the diff system compares incoming data against the current database state and tells you exactly what changed:
|
||||
|
||||
```python
|
||||
@client
|
||||
def update_articles(request, articles: list[ArticleShape]) -> dict:
|
||||
for article, diff in ArticleShape.diff_many(articles):
|
||||
if diff.is_new:
|
||||
create_article(article)
|
||||
elif diff.changed:
|
||||
update_fields(article, diff.changed)
|
||||
for tag in diff.tags.created:
|
||||
add_tag(article, tag)
|
||||
for tag_id in diff.tags.deleted:
|
||||
remove_tag(article, tag_id)
|
||||
return {"ok": True}
|
||||
```
|
||||
|
||||
One query fetches all current state. The diff is per-field and per-nested-relation. Your service code only touches what actually changed.
|
||||
|
||||
## The `@client` decorator
|
||||
|
||||
The decorator controls transport, caching, auth, and SSR behavior:
|
||||
|
||||
| Decorator | React hook | What it does |
|
||||
|-----------|-----------|--------------|
|
||||
| `@client` | `useEcho()` | HTTP call, returns typed result |
|
||||
| `@client(context='global')` | `useCurrentUser()` | Fetched once, cached in context, SSR-hydrated |
|
||||
| `@client(context='local')` | `useArticle({ id })` | Cached per unique params |
|
||||
| `@client(websocket=True)` | `useSearch()` | Runs over WebSocket instead of HTTP |
|
||||
| `@client(auth=True)` | — | Requires authentication |
|
||||
| `@client(auth='staff')` | — | Requires staff status |
|
||||
| `@client(auth=my_check)` | — | Custom auth callable |
|
||||
Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
|
||||
|
||||
## Forms
|
||||
|
||||
Django forms become typed React hooks with client-side Zod validation:
|
||||
Django forms get typed React hooks with client-side Zod validation:
|
||||
|
||||
```python
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
# Django
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
@@ -216,22 +221,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React (generated)
|
||||
const form = useContactForm()
|
||||
|
||||
form.schema // field metadata, title, submit label
|
||||
form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
|
||||
form.data // { name: '', email: '', message: '' }
|
||||
form.set('email', v) // typed setter
|
||||
form.errors // field-level errors (Zod + server)
|
||||
form.submit() // → { success: true, data: { sent: true } }
|
||||
```
|
||||
|
||||
Zod schemas are generated from the Django form definition. Validation runs client-side first, server-side second. No duplicated validation logic.
|
||||
|
||||
## Channels
|
||||
|
||||
WebSocket channels with typed messages:
|
||||
|
||||
```python
|
||||
# Django
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
@@ -252,6 +257,7 @@ class ChatChannel(ReactChannel):
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React (generated)
|
||||
const chat = useChatChannel({ room: 'general' })
|
||||
|
||||
chat.status // 'connecting' | 'connected' | 'disconnected'
|
||||
@@ -259,111 +265,32 @@ chat.messages // ChatDjangoMessage[]
|
||||
chat.send({ text: 'hello' })
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
React app
|
||||
└─ <DjangoContext> ← generated provider (session, CSRF, WebSocket)
|
||||
├─ useCurrentUser() ← context hook (SSR-hydrated)
|
||||
├─ useEcho() ← function hook
|
||||
├─ useContactForm() ← form hook (Zod + server validation)
|
||||
└─ useChatChannel() ← channel hook (WebSocket)
|
||||
│
|
||||
├─ HTTP: POST /api/djarea/call/ { fn: "echo", args: { text: "hi" } }
|
||||
└─ WS: { action: "rpc", fn: "echo", args: { text: "hi" } }
|
||||
│
|
||||
Django executor
|
||||
├─ Pydantic input validation
|
||||
├─ Auth check
|
||||
├─ Function execution
|
||||
└─ Pydantic output serialization
|
||||
```
|
||||
|
||||
All transport goes through a single endpoint. The generated `DjangoContext` is the only provider. It handles session init, CSRF, context auto-fetching, and WebSocket connection.
|
||||
|
||||
## Code generation
|
||||
|
||||
`npx djarea-generate` reads Django schemas at build time (no running server) and produces:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `generated.djarea.ts` | Pydantic model types |
|
||||
| `generated.django.tsx` | `DjangoContext` provider + typed hooks |
|
||||
| `generated.django.server.ts` | SSR hydration helper |
|
||||
| `generated.forms.ts` | Form hooks with Zod schemas |
|
||||
| `generated.channels.ts` | Channel message types |
|
||||
| `generated.channels.hooks.tsx` | Channel hooks |
|
||||
| `index.ts` | Re-exports |
|
||||
|
||||
## Error handling
|
||||
|
||||
All errors from server functions throw as `DjangoError`:
|
||||
|
||||
```tsx
|
||||
if (e instanceof DjangoError) {
|
||||
e.code // 'NOT_FOUND' | 'VALIDATION_ERROR' | 'UNAUTHORIZED' | ...
|
||||
e.message // human-readable
|
||||
e.details // field-level validation errors
|
||||
e.isAuthError()
|
||||
e.isValidationError()
|
||||
e.getFieldErrors('email')
|
||||
}
|
||||
```
|
||||
|
||||
## Why RPC instead of REST
|
||||
|
||||
REST exposes your database tables as CRUD endpoints and pushes business logic to the frontend. "Submit an application" becomes PATCH one resource, POST another, PUT a third — choreographed by client code.
|
||||
|
||||
Djarea keeps business logic on the server. You write functions that do things. The frontend calls them. The server knows what "submit" means. The client doesn't need to.
|
||||
|
||||
If you delete the frontend of a REST app, your backend is a database. If you delete the frontend of a Djarea app, your backend still has your entire application logic.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Install |
|
||||
|---------|---------|
|
||||
| `djarea` (Python) | `pip install djarea` |
|
||||
| `@rythazhur/djarea` (TypeScript) | `npm install @rythazhur/djarea` |
|
||||
|
||||
For WebSocket support: `pip install "djarea[channels]"`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Django
|
||||
cd django && uv run pytest
|
||||
# Django unit tests
|
||||
cd django && uv sync --extra dev --extra channels && uv run pytest
|
||||
|
||||
# React
|
||||
# React unit tests
|
||||
cd react && npm test
|
||||
|
||||
# E2E (Playwright, real browser + real backend)
|
||||
# E2E integration tests (real browser, real backend)
|
||||
docker compose -f docker-compose.test.yml up -d
|
||||
cd e2e/harness && npx djarea-generate && npx playwright test
|
||||
cd e2e/harness && npm install && npx mizan-generate && npx vite --port 5174 &
|
||||
npx playwright test
|
||||
|
||||
# Everything
|
||||
# All at once
|
||||
make test-all
|
||||
```
|
||||
|
||||
## Project structure
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
djarea/
|
||||
django/ Python package
|
||||
react/ TypeScript package
|
||||
example/ Integration test backend
|
||||
e2e/ Playwright E2E tests
|
||||
mizan/
|
||||
django/ Python package (mizan)
|
||||
react/ TypeScript package (@rythazhur/mizan)
|
||||
example/ Integration test backend (Docker)
|
||||
desktop/ PyWebView desktop test app
|
||||
e2e/ Playwright E2E tests + React harness
|
||||
Makefile Test orchestration
|
||||
```
|
||||
|
||||
## Disclosure
|
||||
|
||||
Djarea was developed with the assistance of IDE AI Assistance and later with Claude Code.
|
||||
|
||||
The architecture, design decisions, developer experience standards and technical direction are mine. I've been programming for 16 years and have a lot of opinions!
|
||||
|
||||
DX ideas are inspired by the amazing work of these projects and the hardworking folks behind them:
|
||||
- Django Ninja
|
||||
- Django Readers
|
||||
- Django RAPID Architecture
|
||||
- React
|
||||
- Next.js
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Djarea Desktop — PyWebView + Django local RPC.
|
||||
mizan Desktop — PyWebView + Django local RPC.
|
||||
|
||||
Starts a local Django ASGI server and opens a native desktop window.
|
||||
All communication between the UI and backend uses Djarea server functions.
|
||||
All communication between the UI and backend uses mizan server functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -63,7 +63,7 @@ def main():
|
||||
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
if not wait_for_server(f"{base_url}/api/djarea/session/"):
|
||||
if not wait_for_server(f"{base_url}/api/mizan/session/"):
|
||||
print("ERROR: Django server failed to start", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -83,7 +83,7 @@ def main():
|
||||
import webview
|
||||
|
||||
window = webview.create_window(
|
||||
title="Djarea Desktop",
|
||||
title="mizan Desktop",
|
||||
url=base_url,
|
||||
width=1024,
|
||||
height=768,
|
||||
|
||||
@@ -6,8 +6,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
|
||||
import backend.djarea_clients # noqa: F401
|
||||
import backend.mizan_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Desktop RPC server functions.
|
||||
|
||||
Tests Djarea's appropriateness for desktop apps:
|
||||
Tests mizan's appropriateness for desktop apps:
|
||||
- Local file system access
|
||||
- SQLite CRUD
|
||||
- System introspection
|
||||
@@ -20,10 +20,10 @@ from pathlib import Path
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from djarea.channels import ReactChannel
|
||||
from djarea.setup.registry import register
|
||||
from djarea.channels import register as register_channel
|
||||
from mizan.client import client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.setup.registry import register
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -40,12 +40,12 @@ class SystemInfoOutput(BaseModel):
|
||||
home_dir: str
|
||||
cwd: str
|
||||
cpu_count: int
|
||||
djarea_version: str
|
||||
mizan_version: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||
import djarea
|
||||
import mizan
|
||||
|
||||
return SystemInfoOutput(
|
||||
os_name=platform.system(),
|
||||
@@ -56,7 +56,7 @@ def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||
home_dir=str(Path.home()),
|
||||
cwd=os.getcwd(),
|
||||
cpu_count=os.cpu_count() or 1,
|
||||
djarea_version=getattr(djarea, "__version__", "dev"),
|
||||
mizan_version=getattr(mizan, "__version__", "dev"),
|
||||
)
|
||||
|
||||
|
||||
@@ -114,16 +114,20 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
for entry in sorted(
|
||||
dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())
|
||||
):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append(FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
))
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except PermissionError:
|
||||
@@ -268,7 +272,9 @@ register(list_notes, "list_notes")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def create_note(request: HttpRequest, title: str, content: str = "", pinned: bool = False) -> NoteOutput:
|
||||
def create_note(
|
||||
request: HttpRequest, title: str, content: str = "", pinned: bool = False
|
||||
) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
||||
@@ -403,7 +409,7 @@ def app_info(request: HttpRequest) -> AppInfoOutput:
|
||||
from django.conf import settings
|
||||
|
||||
return AppInfoOutput(
|
||||
app_name="Djarea Desktop",
|
||||
app_name="mizan Desktop",
|
||||
uptime_seconds=round(time.time() - _start_time, 2),
|
||||
db_path=str(settings.DATABASES["default"]["NAME"]),
|
||||
pid=os.getpid(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Django settings for the Djarea desktop integration test app.
|
||||
Django settings for the mizan desktop integration test app.
|
||||
|
||||
Runs entirely local: SQLite database, in-memory channel layer,
|
||||
no external services required.
|
||||
|
||||
@@ -27,7 +27,7 @@ def serve_dist(request, path="index.html"):
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
re_path(r"^(?P<path>assets/.+)$", serve_dist),
|
||||
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
|
||||
path("", serve_dist),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Djarea Desktop</title>
|
||||
<title>mizan Desktop</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "djarea-desktop-frontend",
|
||||
"name": "mizan-desktop-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rythazhur/djarea": "file:../../react",
|
||||
"@rythazhur/mizan": "file:../../react",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { DjareaProvider, useDjarea, useDjareaStatus } from '@rythazhur/djarea'
|
||||
import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
|
||||
|
||||
// ─── System Info ────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemInfo() {
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +33,7 @@ function SystemInfo() {
|
||||
// ─── Connection Status ──────────────────────────────────────────────────────
|
||||
|
||||
function StatusBar() {
|
||||
const status = useDjareaStatus()
|
||||
const status = useMizanStatus()
|
||||
return (
|
||||
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
||||
{status}
|
||||
@@ -46,7 +46,7 @@ function StatusBar() {
|
||||
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
||||
|
||||
function Notes() {
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [selected, setSelected] = useState<Note | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -140,7 +140,7 @@ function Notes() {
|
||||
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
||||
|
||||
function FileBrowser() {
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [dir, setDir] = useState('~')
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
const [parent, setParent] = useState<string | null>(null)
|
||||
@@ -184,17 +184,17 @@ function FileBrowser() {
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DjareaProvider baseUrl="/api/djarea" autoConnect={false}>
|
||||
<MizanProvider baseUrl="/api/mizan" autoConnect={false}>
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, color: '#fff' }}>Djarea Desktop</h1>
|
||||
<h1 style={{ fontSize: 24, color: '#fff' }}>mizan Desktop</h1>
|
||||
<StatusBar />
|
||||
</div>
|
||||
<SystemInfo />
|
||||
<Notes />
|
||||
<FileBrowser />
|
||||
</div>
|
||||
</DjareaProvider>
|
||||
</MizanProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
[project]
|
||||
name = "djarea-desktop"
|
||||
name = "mizan-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Desktop integration test app for Djarea"
|
||||
description = "Desktop integration test app for mizan"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"djarea[channels]",
|
||||
"mizan[channels]",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"pywebview[qt]>=5.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
djarea = { path = "../django", editable = true }
|
||||
mizan = { path = "../django", editable = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
# Ensure migrations run before tests
|
||||
def pytest_configure():
|
||||
# Import djarea_clients to trigger function registration
|
||||
import backend.djarea_clients # noqa: F401
|
||||
# Import mizan_clients to trigger function registration
|
||||
import backend.mizan_clients # noqa: F401
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
REAL integration tests for the Djarea RPC framework layer.
|
||||
REAL integration tests for the mizan RPC framework layer.
|
||||
|
||||
Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
|
||||
Every test makes a real HTTP request — no mocks, no RequestFactory.
|
||||
@@ -14,7 +14,7 @@ from django.test import LiveServerTestCase
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
@@ -26,7 +26,7 @@ class RealHTTPMixin:
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -37,7 +37,13 @@ class RealHTTPMixin:
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
def _raw_post(self, path: str, body: bytes | str, content_type: str = "application/json", include_csrf: bool = False):
|
||||
def _raw_post(
|
||||
self,
|
||||
path: str,
|
||||
body: bytes | str,
|
||||
content_type: str = "application/json",
|
||||
include_csrf: bool = False,
|
||||
):
|
||||
"""Raw POST without the call() envelope — for testing malformed requests."""
|
||||
url = f"{self.live_server_url}{path}"
|
||||
if isinstance(body, str):
|
||||
@@ -55,7 +61,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def test_session_endpoint_sets_csrf_cookie(self):
|
||||
"""GET /session/ must return a Set-Cookie with csrftoken."""
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
|
||||
@@ -64,7 +70,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def test_call_without_csrf_is_rejected(self):
|
||||
"""POST /call/ without CSRF token must fail."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": "system_info", "args": {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -134,7 +140,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def test_get_method_rejected(self):
|
||||
"""GET to /call/ should be rejected."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
try:
|
||||
resp = urlopen(Request(url))
|
||||
data = json.loads(resp.read())
|
||||
@@ -147,7 +153,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
body="not valid json{{{",
|
||||
include_csrf=True,
|
||||
)
|
||||
@@ -162,7 +168,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
body=json.dumps({"not_fn": "hello"}),
|
||||
include_csrf=True,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from urllib.request import urlopen, Request
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
@@ -24,7 +24,7 @@ class RealHTTPMixin:
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -105,6 +105,7 @@ class NotesCRUDTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
# Verify it's gone
|
||||
from urllib.error import HTTPError
|
||||
|
||||
try:
|
||||
get_data = self._call("get_note", {"id": note_id})
|
||||
self.assertTrue(get_data["error"])
|
||||
|
||||
@@ -18,8 +18,8 @@ class RealHTTPMixin:
|
||||
"""Makes real HTTP requests to the live server."""
|
||||
|
||||
def _session_init(self):
|
||||
"""Hit /session/ to get CSRF cookie, like DjareaProvider does."""
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
"""Hit /session/ to get CSRF cookie, like mizanProvider does."""
|
||||
url = f"{self.live_server_url}/api/mizan/session/"
|
||||
req = Request(url)
|
||||
resp = urlopen(req)
|
||||
# Extract csrftoken from Set-Cookie header
|
||||
@@ -33,8 +33,8 @@ class RealHTTPMixin:
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
"""Make a real POST to /api/djarea/call/ with CSRF token."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
"""Make a real POST to /api/mizan/call/ with CSRF token."""
|
||||
url = f"{self.live_server_url}/api/mizan/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
@@ -80,7 +80,7 @@ class SystemInfoTests(RealHTTPMixin, LiveServerTestCase):
|
||||
data = self._call("app_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["app_name"], "Djarea Desktop")
|
||||
self.assertEqual(data["data"]["app_name"], "mizan Desktop")
|
||||
self.assertGreater(data["data"]["uptime_seconds"], 0)
|
||||
|
||||
|
||||
@@ -89,11 +89,12 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
self.test_dir = Path.home() / ".djarea-test"
|
||||
self.test_dir = Path.home() / ".mizan-test"
|
||||
self.test_dir.mkdir(exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
if self.test_dir.exists():
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
@@ -116,7 +117,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
test_content = "Hello from a REAL HTTP integration test!"
|
||||
|
||||
# Write
|
||||
write_data = self._call("write_file", {"path": test_path, "content": test_content})
|
||||
write_data = self._call(
|
||||
"write_file", {"path": test_path, "content": test_content}
|
||||
)
|
||||
self.assertFalse(write_data["error"])
|
||||
self.assertEqual(write_data["data"]["path"], test_path)
|
||||
|
||||
@@ -130,7 +133,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
from urllib.error import HTTPError
|
||||
|
||||
try:
|
||||
data = self._call("write_file", {"path": "/tmp/escape.txt", "content": "nope"})
|
||||
data = self._call(
|
||||
"write_file", {"path": "/tmp/escape.txt", "content": "nope"}
|
||||
)
|
||||
# If we get here, check the response has an error
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "FORBIDDEN")
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
# djarea (Python)
|
||||
# mizan (Python)
|
||||
|
||||
Django server functions framework. See the [monorepo root](../README.md) for full documentation.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
uv add "djarea[channels,allauth] @ git+https://git.impactsoundworks.com/isw/djarea.git#subdirectory=django"
|
||||
uv add "mizan[channels,allauth] @ git+https://git.impactsoundworks.com/isw/mizan.git#subdirectory=django"
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```python
|
||||
# settings.py
|
||||
INSTALLED_APPS = ["djarea", ...]
|
||||
INSTALLED_APPS = ["mizan", ...]
|
||||
|
||||
# urls.py
|
||||
path("api/djarea/", include("djarea.urls"))
|
||||
path("api/mizan/", include("mizan.urls"))
|
||||
|
||||
# asgi.py (optional, for WebSocket)
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
```
|
||||
|
||||
## Define Functions
|
||||
|
||||
```python
|
||||
from djarea.client import client
|
||||
from djarea.setup.registry import register
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Output(BaseModel):
|
||||
@@ -43,7 +43,7 @@ Register in `apps.py`:
|
||||
|
||||
```python
|
||||
def ready(self):
|
||||
import myapp.djarea_clients
|
||||
import myapp.mizan_clients
|
||||
```
|
||||
|
||||
## Auth
|
||||
@@ -65,10 +65,10 @@ def ready(self):
|
||||
## Forms
|
||||
|
||||
```python
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="contact", title="Contact Us")
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="contact", title="Contact Us")
|
||||
name = forms.CharField()
|
||||
email = forms.EmailField()
|
||||
|
||||
@@ -81,7 +81,7 @@ Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Generates
|
||||
## Channels
|
||||
|
||||
```python
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[project]
|
||||
name = "djarea"
|
||||
name = "mizan"
|
||||
version = "1.0.1"
|
||||
description = "Django + React server functions framework"
|
||||
readme = "README.md"
|
||||
@@ -36,11 +36,11 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/djarea"]
|
||||
packages = ["src/mizan"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||
pythonpath = ["src", "."]
|
||||
testpaths = ["src/djarea/tests"]
|
||||
testpaths = ["src/mizan/tests"]
|
||||
python_classes = ["*Tests", "*Test", "Test*"]
|
||||
python_functions = ["test_*"]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from djarea.shapes.core import Diff, NestedDiff, Shape
|
||||
|
||||
__all__ = ["Diff", "NestedDiff", "Shape"]
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea - Django + React unified framework
|
||||
mizan - Django + React unified framework
|
||||
|
||||
Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
@@ -7,16 +7,16 @@ Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
### 1. urls.py - HTTP endpoint
|
||||
```python
|
||||
from djarea import urls as djarea_urls
|
||||
from mizan import urls as mizan_urls
|
||||
|
||||
urlpatterns = [
|
||||
path('api/djarea/', include(djarea_urls)),
|
||||
path('api/mizan/', include(mizan_urls)),
|
||||
]
|
||||
```
|
||||
|
||||
### 2. asgi.py - WebSocket support (optional)
|
||||
```python
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
@@ -25,7 +25,7 @@ application = wrap_asgi(get_asgi_application())
|
||||
### 3. Define server functions
|
||||
```python
|
||||
# apps/myapp/clients.py
|
||||
from djarea import client
|
||||
from mizan import client
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
@@ -51,8 +51,8 @@ def send_message(request, room_id: int, text: str) -> MessageOutput:
|
||||
```python
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
from djarea.setup import djarea_clients
|
||||
djarea_clients('apps')
|
||||
from mizan.setup import mizan_clients
|
||||
mizan_clients('apps')
|
||||
```
|
||||
|
||||
### 5. Frontend - generate types and use
|
||||
@@ -76,7 +76,7 @@ await echo({ text: 'hello' })
|
||||
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
|
||||
| `@client(websocket=True)` | `useXxx()` hook | WebSocket |
|
||||
| `@compose(...)` | `<XxxProvider>` combined | varies |
|
||||
| `DjareaFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||
| `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
|
||||
| `ReactChannel` | `useXxxChannel()` | WebSocket |
|
||||
"""
|
||||
|
||||
@@ -89,11 +89,12 @@ from . import setup
|
||||
from .channels import ReactChannel
|
||||
from .channels import register as register_channel
|
||||
from .client import ComposedContext, ServerFunction, client, compose
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
|
||||
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||
# imports contenttypes, which can't happen during apps.populate()
|
||||
from .setup import (
|
||||
djarea_clients,
|
||||
djarea_module,
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
get_channel,
|
||||
get_function,
|
||||
register,
|
||||
@@ -104,9 +105,9 @@ from .setup import (
|
||||
def __getattr__(name):
|
||||
"""Lazy loading for modules that can't be imported at app load time."""
|
||||
if name == "urls":
|
||||
from .urls import urlpatterns as djarea_patterns
|
||||
from .urls import urlpatterns as mizan_patterns
|
||||
|
||||
return djarea_patterns
|
||||
return mizan_patterns
|
||||
if name == "Shape":
|
||||
from .shapes import Shape
|
||||
|
||||
@@ -116,11 +117,11 @@ def __getattr__(name):
|
||||
|
||||
def wrap_asgi(http_application):
|
||||
"""
|
||||
Wrap an ASGI application with Djarea WebSocket support.
|
||||
Wrap an ASGI application with mizan WebSocket support.
|
||||
|
||||
Usage in asgi.py:
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
|
||||
@@ -162,8 +163,8 @@ __all__ = [
|
||||
"ServerFunction",
|
||||
"ComposedContext",
|
||||
# Setup
|
||||
"djarea_clients",
|
||||
"djarea_module",
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
"register",
|
||||
"register_as",
|
||||
"get_function",
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.channels - Real-time WebSocket communication.
|
||||
mizan.channels - Real-time WebSocket communication.
|
||||
|
||||
Type-safe bidirectional messaging between Django and React via WebSockets.
|
||||
Hooks are auto-generated with full TypeScript types.
|
||||
@@ -9,7 +9,7 @@ Hooks are auto-generated with full TypeScript types.
|
||||
```python
|
||||
# channels.py
|
||||
from pydantic import BaseModel
|
||||
from djarea import channels
|
||||
from mizan import channels
|
||||
|
||||
class ChatChannel(channels.ReactChannel):
|
||||
|
||||
@@ -42,7 +42,7 @@ channels.register(ChatChannel, 'chat')
|
||||
|
||||
```python
|
||||
# asgi.py
|
||||
from djarea import channels
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
@@ -88,6 +88,7 @@ logger = logging.getLogger(__name__)
|
||||
# Base Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactChannel:
|
||||
"""
|
||||
Base class for WebSocket channels.
|
||||
@@ -140,9 +141,7 @@ class ReactChannel:
|
||||
|
||||
Messages returned from receive() are broadcast to this group.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} must implement group()"
|
||||
)
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
|
||||
|
||||
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
|
||||
"""
|
||||
@@ -191,9 +190,9 @@ class ReactChannel:
|
||||
"type": "channel.message",
|
||||
"channel": self._registered_name,
|
||||
"params": self._params_dict,
|
||||
"data": message.model_dump(mode='json'),
|
||||
"data": message.model_dump(mode="json"),
|
||||
"message_type": message.__class__.__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -215,7 +214,9 @@ class ReactChannel:
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if not channel_layer:
|
||||
logger.warning(f"No channel layer configured, cannot push to {cls.__name__}")
|
||||
logger.warning(
|
||||
f"No channel layer configured, cannot push to {cls.__name__}"
|
||||
)
|
||||
return
|
||||
|
||||
# Build params model if defined
|
||||
@@ -234,9 +235,9 @@ class ReactChannel:
|
||||
"type": "channel.message",
|
||||
"channel": cls._registered_name,
|
||||
"params": params,
|
||||
"data": message.model_dump(mode='json'),
|
||||
"data": message.model_dump(mode="json"),
|
||||
"message_type": message.__class__.__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -261,9 +262,9 @@ def register(channel_class: Type[ReactChannel], name: str) -> None:
|
||||
channel_class._registered_name = name
|
||||
|
||||
# Validate the channel class
|
||||
if not hasattr(channel_class, 'authorize'):
|
||||
if not hasattr(channel_class, "authorize"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement authorize()")
|
||||
if not hasattr(channel_class, 'group'):
|
||||
if not hasattr(channel_class, "group"):
|
||||
raise ValueError(f"{channel_class.__name__} must implement group()")
|
||||
|
||||
_registry[name] = channel_class
|
||||
@@ -284,12 +285,13 @@ def get_registered_channels() -> dict[str, Type[ReactChannel]]:
|
||||
# WebSocket Consumer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_websocket_application():
|
||||
"""
|
||||
Get the WebSocket application for ASGI.
|
||||
|
||||
Usage in asgi.py:
|
||||
from djarea import channels
|
||||
from mizan import channels
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
@@ -309,9 +311,11 @@ def get_websocket_application():
|
||||
from .connection import DjangoReactConsumer
|
||||
|
||||
return AuthMiddlewareStack(
|
||||
URLRouter([
|
||||
path("ws/", DjangoReactConsumer.as_asgi()),
|
||||
])
|
||||
URLRouter(
|
||||
[
|
||||
path("ws/", DjangoReactConsumer.as_asgi()),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -319,15 +323,14 @@ def get_websocket_application():
|
||||
# Schema Export (for TypeScript generation)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_channels_schema() -> dict:
|
||||
"""
|
||||
Get schema for all registered channels (for TypeScript generation).
|
||||
|
||||
Returns a dict suitable for the frontend code generator.
|
||||
"""
|
||||
schema = {
|
||||
"channels": {}
|
||||
}
|
||||
schema = {"channels": {}}
|
||||
|
||||
for name, channel_class in _registry.items():
|
||||
channel_schema = {
|
||||
@@ -338,16 +341,20 @@ def get_channels_schema() -> dict:
|
||||
}
|
||||
|
||||
# Extract Params schema
|
||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
|
||||
# Extract ReactMessage schema
|
||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
||||
channel_schema["reactMessage"] = channel_class.ReactMessage.model_json_schema()
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
channel_schema[
|
||||
"reactMessage"
|
||||
] = channel_class.ReactMessage.model_json_schema()
|
||||
|
||||
# Extract DjangoMessage schema
|
||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
||||
channel_schema["djangoMessage"] = channel_class.DjangoMessage.model_json_schema()
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
channel_schema[
|
||||
"djangoMessage"
|
||||
] = channel_class.DjangoMessage.model_json_schema()
|
||||
|
||||
schema["channels"][name] = channel_schema
|
||||
|
||||
@@ -364,14 +371,19 @@ def _register_channel_schema_endpoint(
|
||||
) -> None:
|
||||
"""Register a dummy endpoint for schema generation (avoids closure issues)."""
|
||||
if input_cls is not None:
|
||||
|
||||
def endpoint(request, data):
|
||||
pass
|
||||
|
||||
endpoint.__annotations__ = {"data": input_cls}
|
||||
else:
|
||||
|
||||
def endpoint(request):
|
||||
pass
|
||||
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint)
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||
endpoint
|
||||
)
|
||||
|
||||
|
||||
def get_channels_openapi_schema() -> dict:
|
||||
@@ -386,9 +398,9 @@ def get_channels_openapi_schema() -> dict:
|
||||
|
||||
# Create temporary Ninja API for schema generation only
|
||||
schema_api = NinjaAPI(
|
||||
title="Djarea Channels",
|
||||
title="mizan Channels",
|
||||
version="1.0.0",
|
||||
description="Auto-generated schema for djarea channels",
|
||||
description="Auto-generated schema for mizan channels",
|
||||
docs_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
@@ -409,7 +421,7 @@ def get_channels_openapi_schema() -> dict:
|
||||
}
|
||||
|
||||
# Register Params type
|
||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
params_name = f"{pascal_name}Params"
|
||||
schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
|
||||
channel_meta["hasParams"] = True
|
||||
@@ -426,9 +438,11 @@ def get_channels_openapi_schema() -> dict:
|
||||
)
|
||||
|
||||
# Register ReactMessage type
|
||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
react_name = f"{pascal_name}ReactMessage"
|
||||
schema_classes[react_name] = type(react_name, (channel_class.ReactMessage,), {})
|
||||
schema_classes[react_name] = type(
|
||||
react_name, (channel_class.ReactMessage,), {}
|
||||
)
|
||||
channel_meta["hasReactMessage"] = True
|
||||
channel_meta["reactMessageType"] = react_name
|
||||
|
||||
@@ -442,9 +456,11 @@ def get_channels_openapi_schema() -> dict:
|
||||
)
|
||||
|
||||
# Register DjangoMessage type
|
||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
django_name = f"{pascal_name}DjangoMessage"
|
||||
schema_classes[django_name] = type(django_name, (channel_class.DjangoMessage,), {})
|
||||
schema_classes[django_name] = type(
|
||||
django_name, (channel_class.DjangoMessage,), {}
|
||||
)
|
||||
channel_meta["hasDjangoMessage"] = True
|
||||
channel_meta["djangoMessageType"] = django_name
|
||||
|
||||
@@ -464,7 +480,7 @@ def get_channels_openapi_schema() -> dict:
|
||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||
|
||||
# Add channel metadata extension
|
||||
schema["x-djarea-channels"] = channel_metadata
|
||||
schema["x-mizan-channels"] = channel_metadata
|
||||
|
||||
return schema
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
WebSocket consumer for djarea.channels.
|
||||
WebSocket consumer for mizan.channels.
|
||||
|
||||
Handles multiplexed channel subscriptions AND RPC calls over a single WebSocket connection.
|
||||
|
||||
@@ -100,7 +100,9 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self._try_jwt_auth()
|
||||
|
||||
await self.accept()
|
||||
logger.debug(f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}")
|
||||
logger.debug(
|
||||
f"WebSocket connected: {self.channel_name}, user={self.scope.get('user')}"
|
||||
)
|
||||
|
||||
async def _try_jwt_auth(self):
|
||||
"""
|
||||
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Validate JWT and create JWTUser (no DB query)
|
||||
try:
|
||||
from djarea.client.jwt import decode_token
|
||||
from djarea.jwt.tokens import JWTUser
|
||||
from mizan.client.jwt import decode_token
|
||||
from mizan.jwt.tokens import JWTUser
|
||||
|
||||
payload = await sync_to_async(decode_token)(token, expected_type="access")
|
||||
if payload is None:
|
||||
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
elif action == "rpc":
|
||||
await self._handle_rpc(content)
|
||||
else:
|
||||
await self.send_json({
|
||||
"error": f"Unknown action: {action}",
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Unknown action: {action}",
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_subscribe(self, content: dict):
|
||||
"""Handle subscription request."""
|
||||
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Get channel class
|
||||
channel_class = get_channel(channel_name)
|
||||
if not channel_class:
|
||||
await self.send_json({
|
||||
"error": f"Unknown channel: {channel_name}",
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Unknown channel: {channel_name}",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Create subscription key
|
||||
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Check if already subscribed
|
||||
if sub_key in self._subscriptions:
|
||||
await self.send_json({
|
||||
"error": f"Already subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Already subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Create channel instance
|
||||
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
try:
|
||||
params_obj = channel_class.Params(**params_dict)
|
||||
except Exception as e:
|
||||
await self.send_json({
|
||||
"error": f"Invalid params: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Invalid params: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Check authorization
|
||||
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
authorized = instance.authorize()
|
||||
except Exception as e:
|
||||
logger.error(f"Authorization error for {channel_name}: {e}")
|
||||
await self.send_json({
|
||||
"error": "Authorization failed",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": "Authorization failed",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if not authorized:
|
||||
await self.send_json({
|
||||
"error": "Not authorized",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": "Not authorized",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Get group and join
|
||||
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
await instance._join_group(group_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to join group for {channel_name}: {e}")
|
||||
await self.send_json({
|
||||
"error": f"Failed to subscribe: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Failed to subscribe: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Store subscription
|
||||
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
logger.error(f"on_connect error for {channel_name}: {e}")
|
||||
|
||||
# Confirm subscription
|
||||
await self.send_json({
|
||||
"subscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"subscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Subscribed to {channel_name} with params {params_dict}")
|
||||
|
||||
@@ -286,11 +304,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
except Exception as e:
|
||||
logger.error(f"Error during unsubscribe: {e}")
|
||||
|
||||
await self.send_json({
|
||||
"unsubscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"unsubscribed": True,
|
||||
"channel": channel_name,
|
||||
"params": params_dict,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Unsubscribed from {channel_name}")
|
||||
|
||||
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
instance = self._subscriptions.get(sub_key)
|
||||
if not instance:
|
||||
await self.send_json({
|
||||
"error": f"Not subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Not subscribed to {channel_name}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
channel_class = instance.__class__
|
||||
|
||||
# Check if channel accepts messages
|
||||
if not channel_class.ReactMessage:
|
||||
await self.send_json({
|
||||
"error": f"Channel {channel_name} does not accept messages",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Channel {channel_name} does not accept messages",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Parse message
|
||||
try:
|
||||
msg = channel_class.ReactMessage(**data)
|
||||
except Exception as e:
|
||||
await self.send_json({
|
||||
"error": f"Invalid message: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Invalid message: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Parse params
|
||||
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling message for {channel_name}: {e}")
|
||||
await self.send_json({
|
||||
"error": f"Message handling failed: {e}",
|
||||
"channel": channel_name,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": f"Message handling failed: {e}",
|
||||
"channel": channel_name,
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_rpc(self, content: dict):
|
||||
"""
|
||||
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
- Function must be explicitly registered (no arbitrary code execution)
|
||||
- User context from WebSocket session is passed to function
|
||||
"""
|
||||
from djarea.client.executor import execute_function, FunctionError
|
||||
from djarea.setup.registry import get_function
|
||||
from mizan.client.executor import execute_function, FunctionError
|
||||
from mizan.setup.registry import get_function
|
||||
|
||||
request_id = content.get("id")
|
||||
fn_name = content.get("fn")
|
||||
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Validate request structure
|
||||
if not request_id:
|
||||
await self.send_json({
|
||||
"error": "RPC request missing 'id' field",
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"error": "RPC request missing 'id' field",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if not fn_name:
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Missing 'fn' field",
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "BAD_REQUEST",
|
||||
"message": "Missing 'fn' field",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Check if function exists and has websocket=True
|
||||
fn_class = get_function(fn_name)
|
||||
if fn_class is None:
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Function '{fn_name}' not found",
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Function '{fn_name}' not found",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Only allow functions explicitly marked with websocket=True
|
||||
fn_meta = getattr(fn_class, "_meta", {})
|
||||
if not fn_meta.get("websocket"):
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.",
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "This function is HTTP-only. Use POST /api/mizan/call/ instead.",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Create request adapter from WebSocket scope
|
||||
ws_request = WebSocketRequest(self.scope, channel_name=getattr(self, 'channel_name', None))
|
||||
ws_request = WebSocketRequest(
|
||||
self.scope, channel_name=getattr(self, "channel_name", None)
|
||||
)
|
||||
|
||||
# Execute function (Pydantic validation happens inside execute_function)
|
||||
# This is sync, so we need to run it in a thread pool
|
||||
@@ -435,21 +473,25 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Send response
|
||||
if isinstance(result, FunctionError):
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": result.code.value,
|
||||
"message": result.message,
|
||||
**({"details": result.details} if result.details else {}),
|
||||
},
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": result.code.value,
|
||||
"message": result.message,
|
||||
**({"details": result.details} if result.details else {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
await self.send_json({
|
||||
"id": request_id,
|
||||
"ok": True,
|
||||
"data": result.data,
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"id": request_id,
|
||||
"ok": True,
|
||||
"data": result.data,
|
||||
}
|
||||
)
|
||||
|
||||
async def channel_message(self, event: dict):
|
||||
"""
|
||||
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
Called when channel_layer.group_send() is used.
|
||||
Includes channel name and params so the client can route the message.
|
||||
"""
|
||||
await self.send_json({
|
||||
"channel": event.get("channel"),
|
||||
"params": event.get("params", {}),
|
||||
"type": event.get("message_type", "message"),
|
||||
"data": event.get("data", {}),
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"channel": event.get("channel"),
|
||||
"params": event.get("params", {}),
|
||||
"type": event.get("message_type", "message"),
|
||||
"data": event.get("data", {}),
|
||||
}
|
||||
)
|
||||
|
||||
async def push_message(self, event: dict):
|
||||
"""
|
||||
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
||||
Protocol:
|
||||
Server sends: {"type": "push", "topic": "room:42", "data": {...}}
|
||||
"""
|
||||
await self.send_json({
|
||||
"type": "push",
|
||||
"topic": event.get("topic"),
|
||||
"data": event.get("data", {}),
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "push",
|
||||
"topic": event.get("topic"),
|
||||
"data": event.get("data", {}),
|
||||
}
|
||||
)
|
||||
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
Djarea Push - Server-initiated messages to clients.
|
||||
mizan Push - Server-initiated messages to clients.
|
||||
|
||||
Simple API for pushing data to subscribed WebSocket connections.
|
||||
|
||||
Usage:
|
||||
# In a server function - push to all subscribers
|
||||
from djarea.push import push
|
||||
from mizan.push import push
|
||||
|
||||
push("room:42", {"type": "new_message", "data": {...}})
|
||||
|
||||
# Subscribe a connection to a topic (call during context fetch)
|
||||
from djarea.push import subscribe
|
||||
from mizan.push import subscribe
|
||||
|
||||
subscribe(request, "room:42")
|
||||
"""
|
||||
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
||||
"""Get channel layer, returning None if channels is not installed."""
|
||||
try:
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
return get_channel_layer()
|
||||
except ImportError:
|
||||
return None
|
||||
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
|
||||
def _async_to_sync(coro):
|
||||
"""Wrapper for async_to_sync that handles missing channels."""
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
return async_to_sync(coro)
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
||||
channel_layer = _get_channel_layer()
|
||||
if not channel_layer:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
"No channel layer configured, cannot push to topic '%s'", topic
|
||||
)
|
||||
@@ -125,7 +128,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
|
||||
"type": "push.message", # Maps to push_message handler in consumer
|
||||
"topic": topic,
|
||||
"data": data,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
|
||||
"type": "push.message",
|
||||
"topic": topic,
|
||||
"data": data,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.client - Server function implementation.
|
||||
mizan.client - Server function implementation.
|
||||
|
||||
This subpackage contains everything needed to make server functions work:
|
||||
- The @client decorator
|
||||
@@ -8,7 +8,7 @@ This subpackage contains everything needed to make server functions work:
|
||||
- JWT authentication (integral to server functions)
|
||||
|
||||
Usage:
|
||||
from djarea.client import client, ServerFunction, compose
|
||||
from mizan.client import client, ServerFunction, compose
|
||||
"""
|
||||
|
||||
from .function import (
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Function Executor
|
||||
mizan Function Executor
|
||||
|
||||
Handles execution of server functions.
|
||||
This is the core of the "Server Functions" feature - callable from React
|
||||
@@ -27,7 +27,7 @@ from django.http import HttpRequest, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from djarea.setup.registry import get_function
|
||||
from mizan.setup.registry import get_function
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -134,23 +134,23 @@ def _check_auth_requirement(
|
||||
)
|
||||
|
||||
# Check authentication (required for all string-based auth)
|
||||
if not getattr(user, 'is_authenticated', False):
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.UNAUTHORIZED,
|
||||
message="Authentication required",
|
||||
)
|
||||
|
||||
# Check staff requirement
|
||||
if auth_requirement == 'staff':
|
||||
if not getattr(user, 'is_staff', False):
|
||||
if auth_requirement == "staff":
|
||||
if not getattr(user, "is_staff", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Staff access required",
|
||||
)
|
||||
|
||||
# Check superuser requirement
|
||||
elif auth_requirement == 'superuser':
|
||||
if not getattr(user, 'is_superuser', False):
|
||||
elif auth_requirement == "superuser":
|
||||
if not getattr(user, "is_superuser", False):
|
||||
return FunctionError(
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
message="Superuser access required",
|
||||
@@ -224,7 +224,8 @@ def execute_function(
|
||||
if not isinstance(input_data, dict):
|
||||
return FunctionError(
|
||||
code=ErrorCode.BAD_REQUEST,
|
||||
message="Input must be an object, not " + type(input_data).__name__,
|
||||
message="Input must be an object, not "
|
||||
+ type(input_data).__name__,
|
||||
)
|
||||
validated_input = input_cls(**input_data)
|
||||
elif has_input:
|
||||
@@ -280,7 +281,9 @@ def execute_function(
|
||||
code=ErrorCode.INTERNAL_ERROR,
|
||||
message="An internal error occurred",
|
||||
# Don't expose internal details in production
|
||||
details={"type": type(e).__name__} if logger.isEnabledFor(logging.DEBUG) else None,
|
||||
details={"type": type(e).__name__}
|
||||
if logger.isEnabledFor(logging.DEBUG)
|
||||
else None,
|
||||
)
|
||||
|
||||
# Serialize output (handle None for Optional return types)
|
||||
@@ -313,8 +316,8 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
from djarea.client.jwt import decode_token
|
||||
from djarea.jwt.tokens import JWTUser
|
||||
from mizan.client.jwt import decode_token
|
||||
from mizan.jwt.tokens import JWTUser
|
||||
|
||||
payload = decode_token(token, expected_type="access")
|
||||
if payload is None:
|
||||
@@ -322,7 +325,7 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
|
||||
|
||||
# Create JWTUser from token claims - NO DATABASE QUERY
|
||||
request.user = JWTUser(payload)
|
||||
request._djarea_jwt_authenticated = True
|
||||
request._mizan_jwt_authenticated = True
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -379,7 +382,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
|
||||
- Session: Cookie-based with X-CSRFToken header (CSRF required)
|
||||
|
||||
Endpoint: POST /api/djarea/call/
|
||||
Endpoint: POST /api/mizan/call/
|
||||
|
||||
Request body (JSON):
|
||||
{
|
||||
@@ -430,8 +433,8 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
||||
input_data = {k: v for k, v in request.POST.dict().items() if k != "fn"}
|
||||
|
||||
# Attach parsed form data and files to request for form functions
|
||||
request._djarea_form_data = input_data
|
||||
request._djarea_form_files = request.FILES
|
||||
request._mizan_form_data = input_data
|
||||
request._mizan_form_files = request.FILES
|
||||
|
||||
else:
|
||||
# JSON body - standard RPC
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Server Functions - Core Primitive
|
||||
mizan Server Functions - Core Primitive
|
||||
|
||||
Server functions are the core primitive. Everything else builds on them.
|
||||
|
||||
@@ -21,14 +21,25 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, ClassVar, Generic, Literal, TypeVar, Union, get_args, get_origin, get_type_hints
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Generic,
|
||||
Literal,
|
||||
TypeVar,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Valid context modes: 'global', 'local', or False (not a context)
|
||||
ContextMode = Literal['global', 'local', False]
|
||||
ContextMode = Literal["global", "local", False]
|
||||
|
||||
|
||||
TInput = TypeVar("TInput", bound=BaseModel)
|
||||
@@ -167,7 +178,7 @@ class _FunctionWrapper(ServerFunction):
|
||||
|
||||
|
||||
# Valid string values for auth parameter
|
||||
_VALID_AUTH_STRINGS = frozenset({'required', 'staff', 'superuser'})
|
||||
_VALID_AUTH_STRINGS = frozenset({"required", "staff", "superuser"})
|
||||
|
||||
|
||||
def client(
|
||||
@@ -194,7 +205,7 @@ def client(
|
||||
real-time features (chat, gaming, live updates) that benefit
|
||||
from lower latency.
|
||||
|
||||
Note: Forms (DjareaFormMixin) always use HTTP because auth
|
||||
Note: Forms (mizanFormMixin) always use HTTP because auth
|
||||
flows require full HTTP request semantics.
|
||||
|
||||
auth: Authentication requirement.
|
||||
@@ -234,7 +245,7 @@ def client(
|
||||
A ServerFunction class that wraps the function
|
||||
"""
|
||||
# Validate context parameter
|
||||
if context not in (False, 'global', 'local'):
|
||||
if context not in (False, "global", "local"):
|
||||
raise ValueError(
|
||||
f"Invalid context value '{context}'. "
|
||||
f"Must be False, 'global', or 'local'."
|
||||
@@ -249,11 +260,15 @@ def client(
|
||||
)
|
||||
|
||||
def decorator(fn: Callable) -> type[ServerFunction]:
|
||||
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
|
||||
return _create_server_function(
|
||||
fn, context=context, websocket=websocket, auth=auth
|
||||
)
|
||||
|
||||
# Support both @client and @client(...)
|
||||
if fn is not None:
|
||||
return _create_server_function(fn, context=context, websocket=websocket, auth=auth)
|
||||
return _create_server_function(
|
||||
fn, context=context, websocket=websocket, auth=auth
|
||||
)
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -301,9 +316,7 @@ def _create_server_function(
|
||||
# Get output type from return annotation
|
||||
output_type = hints.get("return")
|
||||
if output_type is None:
|
||||
raise TypeError(
|
||||
f"Server function '{name}' must have a return type annotation"
|
||||
)
|
||||
raise TypeError(f"Server function '{name}' must have a return type annotation")
|
||||
|
||||
# Support primitive return types by wrapping in a model with 'result' field
|
||||
# Also handle Optional[X] / X | None by extracting the non-None type
|
||||
@@ -319,7 +332,11 @@ def _create_server_function(
|
||||
args = get_args(t)
|
||||
# Check if any non-None arg is a BaseModel
|
||||
for arg in args:
|
||||
if arg is not type(None) and isinstance(arg, type) and issubclass(arg, BaseModel):
|
||||
if (
|
||||
arg is not type(None)
|
||||
and isinstance(arg, type)
|
||||
and issubclass(arg, BaseModel)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -365,7 +382,7 @@ def _create_server_function(
|
||||
# Auth requirement
|
||||
if auth is not None:
|
||||
if auth is True:
|
||||
meta["auth"] = 'required'
|
||||
meta["auth"] = "required"
|
||||
elif callable(auth):
|
||||
meta["auth"] = auth
|
||||
else:
|
||||
@@ -374,7 +391,7 @@ def _create_server_function(
|
||||
if meta:
|
||||
FunctionWrapper._meta = {**FunctionWrapper._meta, **meta}
|
||||
|
||||
# Note: Registration happens via discovery (djarea_clients), not here.
|
||||
# Note: Registration happens via discovery (mizan_clients), not here.
|
||||
# This allows the decorator to be used without import-time side effects.
|
||||
|
||||
return FunctionWrapper
|
||||
@@ -434,7 +451,7 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
||||
return [item]
|
||||
elif isinstance(item, ComposedContext):
|
||||
return item._leaves.copy()
|
||||
elif hasattr(item, '_leaves'):
|
||||
elif hasattr(item, "_leaves"):
|
||||
# Duck typing for composed contexts
|
||||
return item._leaves.copy()
|
||||
else:
|
||||
@@ -443,11 +460,11 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
|
||||
|
||||
def _is_context_enabled(item) -> bool:
|
||||
"""Check if an item is a context-enabled function or composition."""
|
||||
if isinstance(item, ComposedContext) or hasattr(item, '_leaves'):
|
||||
if isinstance(item, ComposedContext) or hasattr(item, "_leaves"):
|
||||
return True
|
||||
if isinstance(item, type) and issubclass(item, ServerFunction):
|
||||
meta = getattr(item, '_meta', {})
|
||||
return meta.get('context') in ('global', 'local')
|
||||
meta = getattr(item, "_meta", {})
|
||||
return meta.get("context") in ("global", "local")
|
||||
return False
|
||||
|
||||
|
||||
@@ -498,15 +515,18 @@ def compose(
|
||||
Returns:
|
||||
A ComposedContext that can be used in other compositions.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> ComposedContext:
|
||||
from djarea.setup.registry import register_compose
|
||||
from mizan.setup.registry import register_compose
|
||||
|
||||
name = fn.__name__
|
||||
|
||||
# Validate: all children must be context-enabled
|
||||
for i, child in enumerate(children):
|
||||
if not _is_context_enabled(child):
|
||||
child_name = getattr(child, 'name', getattr(child, '__name__', str(child)))
|
||||
child_name = getattr(
|
||||
child, "name", getattr(child, "__name__", str(child))
|
||||
)
|
||||
raise ValueError(
|
||||
f"@compose argument {i} ({child_name}) is not context-enabled. "
|
||||
f"All children must have @client(context='global'|'local') or be @compose."
|
||||
@@ -529,12 +549,16 @@ def compose(
|
||||
|
||||
# Validate transport consistency when on_server=True
|
||||
if on_server:
|
||||
has_websocket = [getattr(leaf, '_meta', {}).get('websocket', False) for leaf in leaves]
|
||||
has_websocket = [
|
||||
getattr(leaf, "_meta", {}).get("websocket", False) for leaf in leaves
|
||||
]
|
||||
|
||||
if websocket:
|
||||
# All must have websocket=True
|
||||
if not all(has_websocket):
|
||||
non_ws = [leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws]
|
||||
non_ws = [
|
||||
leaf.name for leaf, ws in zip(leaves, has_websocket) if not ws
|
||||
]
|
||||
raise ValueError(
|
||||
f"@compose({name}, on_server=True, websocket=True) requires all children "
|
||||
f"to have websocket=True. These are HTTP-only: {non_ws}"
|
||||
@@ -542,7 +566,9 @@ def compose(
|
||||
else:
|
||||
# All must be HTTP-only
|
||||
if any(has_websocket):
|
||||
ws_enabled = [leaf.name for leaf, ws in zip(leaves, has_websocket) if ws]
|
||||
ws_enabled = [
|
||||
leaf.name for leaf, ws in zip(leaves, has_websocket) if ws
|
||||
]
|
||||
raise ValueError(
|
||||
f"@compose({name}, on_server=True, websocket=False) requires all children "
|
||||
f"to be HTTP-only. These have websocket=True: {ws_enabled}"
|
||||
@@ -628,7 +654,7 @@ def create_form_functions(
|
||||
Or use the helper:
|
||||
register_form(ContactForm, 'contact', submit_handler=...)
|
||||
"""
|
||||
from djarea.forms.schema_utils import build_form_schema
|
||||
from mizan.forms.schema_utils import build_form_schema
|
||||
|
||||
# Schema function - returns field definitions
|
||||
class FormSchema(ServerFunction):
|
||||
@@ -644,7 +670,9 @@ def create_form_functions(
|
||||
required=field.required,
|
||||
label=field.label or field.name,
|
||||
help_text=field.help_text or None,
|
||||
choices=[(c.value, c.label) for c in field.choices] if field.choices else None,
|
||||
choices=[(c.value, c.label) for c in field.choices]
|
||||
if field.choices
|
||||
else None,
|
||||
initial=field.initial,
|
||||
)
|
||||
for field in schema.fields
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.client.jwt - JWT authentication for server functions.
|
||||
mizan.client.jwt - JWT authentication for server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
@@ -9,12 +9,12 @@ Server Functions:
|
||||
- jwt_obtain: Convert authenticated session to JWT tokens
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Note: This module is purpose-built for Djarea server functions.
|
||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
"""
|
||||
|
||||
# Token utilities (re-exports from django_jwt_session)
|
||||
from djarea.jwt.tokens import (
|
||||
from mizan.jwt.tokens import (
|
||||
create_token_pair,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
|
||||
)
|
||||
|
||||
# Settings
|
||||
from djarea.jwt.settings import get_settings, JWTSettings
|
||||
from mizan.jwt.settings import get_settings, JWTSettings
|
||||
|
||||
__all__ = [
|
||||
# Token utilities
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea OpenAPI Schema Generator
|
||||
mizan OpenAPI Schema Generator
|
||||
|
||||
Generates OpenAPI 3.0 compatible schema from registered server functions.
|
||||
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion.
|
||||
@@ -11,7 +11,7 @@ NOTE: Schema export is only available via management command for security.
|
||||
HTTP endpoint has been removed to prevent function enumeration.
|
||||
|
||||
Usage:
|
||||
python manage.py export_djarea_schema
|
||||
python manage.py export_mizan_schema
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,12 +21,12 @@ import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
# Lazy imports to avoid Django settings access at module load time
|
||||
# (asgi.py imports djarea before Django is fully configured)
|
||||
# (asgi.py imports mizan before Django is fully configured)
|
||||
if TYPE_CHECKING:
|
||||
from django import forms
|
||||
from ninja import NinjaAPI
|
||||
|
||||
from djarea.setup.registry import get_registry, get_schema
|
||||
from mizan.setup.registry import get_registry, get_schema
|
||||
|
||||
|
||||
__all__ = ["get_schema", "generate_openapi_schema", "generate_openapi_json"]
|
||||
@@ -167,21 +167,26 @@ def _register_schema_endpoint(
|
||||
and exec() security concerns.
|
||||
"""
|
||||
if input_cls is not None:
|
||||
|
||||
def endpoint(request, data):
|
||||
pass
|
||||
|
||||
# Set annotations directly to the actual type objects (not strings)
|
||||
endpoint.__annotations__ = {"data": input_cls}
|
||||
else:
|
||||
|
||||
def endpoint(request):
|
||||
pass
|
||||
|
||||
# Register with Ninja
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(endpoint)
|
||||
api.post(path, response=output_cls, operation_id=operation_id, summary=summary)(
|
||||
endpoint
|
||||
)
|
||||
|
||||
|
||||
def generate_openapi_schema() -> dict[str, Any]:
|
||||
"""
|
||||
Generate OpenAPI 3.0 schema for all registered djarea functions.
|
||||
Generate OpenAPI 3.0 schema for all registered mizan functions.
|
||||
|
||||
Uses Django Ninja's schema generation internally to ensure proper
|
||||
Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).
|
||||
@@ -198,9 +203,9 @@ def generate_openapi_schema() -> dict[str, Any]:
|
||||
# This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
|
||||
# battle-tested Pydantic→OpenAPI conversion
|
||||
schema_api = NinjaAPI(
|
||||
title="Djarea Server Functions",
|
||||
title="mizan Server Functions",
|
||||
version="1.0.0",
|
||||
description="Auto-generated schema for djarea server functions",
|
||||
description="Auto-generated schema for mizan server functions",
|
||||
docs_url=None, # No docs endpoint
|
||||
openapi_url=None, # No openapi endpoint
|
||||
)
|
||||
@@ -234,13 +239,17 @@ def generate_openapi_schema() -> dict[str, Any]:
|
||||
# Store them in schema_classes so they persist beyond loop scope
|
||||
# Uses create_model to avoid metaclass conflicts with custom base classes
|
||||
if has_input:
|
||||
schema_classes[input_type_name] = create_model(input_type_name, __base__=input_cls)
|
||||
schema_classes[output_type_name] = create_model(output_type_name, __base__=output_cls)
|
||||
schema_classes[input_type_name] = create_model(
|
||||
input_type_name, __base__=input_cls
|
||||
)
|
||||
schema_classes[output_type_name] = create_model(
|
||||
output_type_name, __base__=output_cls
|
||||
)
|
||||
|
||||
# Register endpoint using helper to avoid closure capture issues
|
||||
_register_schema_endpoint(
|
||||
api=schema_api,
|
||||
path=f"/djarea/{name}",
|
||||
path=f"/mizan/{name}",
|
||||
operation_id=camel_name,
|
||||
summary=fn_class.__doc__ or f"Call {name}",
|
||||
input_cls=schema_classes.get(input_type_name),
|
||||
@@ -279,13 +288,13 @@ def generate_openapi_schema() -> dict[str, Any]:
|
||||
schema = schema_api.get_openapi_schema(path_prefix="")
|
||||
|
||||
# Add custom extension with function metadata for provider generation
|
||||
schema["x-djarea-functions"] = function_metadata
|
||||
schema["x-mizan-functions"] = function_metadata
|
||||
|
||||
# Add x-djarea metadata to each operation
|
||||
# Add x-mizan metadata to each operation
|
||||
for fn_meta in function_metadata:
|
||||
path = f"/djarea/{fn_meta['name']}"
|
||||
path = f"/mizan/{fn_meta['name']}"
|
||||
if path in schema.get("paths", {}):
|
||||
schema["paths"][path]["post"]["x-djarea"] = {
|
||||
schema["paths"][path]["post"]["x-mizan"] = {
|
||||
"transport": fn_meta["transport"],
|
||||
"isContext": fn_meta["isContext"],
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
DjareaFormMixin - Turn Django Forms into server functions.
|
||||
mizanFormMixin - Turn Django Forms into server functions.
|
||||
|
||||
This mixin transforms any Django Form into Djarea server functions,
|
||||
This mixin transforms any Django Form into mizan server functions,
|
||||
preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
|
||||
while exposing them through the unified server function API.
|
||||
|
||||
Usage:
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
@@ -98,7 +98,7 @@ def _create_form_input_schema(
|
||||
form = form_class()
|
||||
except TypeError:
|
||||
# Form requires extra args (like request) - use form_class.base_fields instead
|
||||
fields_dict = getattr(form_class, 'base_fields', {})
|
||||
fields_dict = getattr(form_class, "base_fields", {})
|
||||
else:
|
||||
fields_dict = form.fields
|
||||
|
||||
@@ -125,9 +125,9 @@ def _create_form_input_schema(
|
||||
return model
|
||||
|
||||
|
||||
class DjareaFormMeta(BaseModel):
|
||||
class mizanFormMeta(BaseModel):
|
||||
"""
|
||||
Configuration for a Djarea form.
|
||||
Configuration for a mizan form.
|
||||
|
||||
This Pydantic model provides type-safe configuration with full LSP support,
|
||||
and serializes to JSON for the frontend schema.
|
||||
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
|
||||
enable_formset: bool = False
|
||||
|
||||
|
||||
class DjareaFormMixin:
|
||||
class mizanFormMixin:
|
||||
"""
|
||||
Mixin that exposes a Django Form as Djarea server functions.
|
||||
Mixin that exposes a Django Form as mizan server functions.
|
||||
|
||||
Add this mixin to any Django Form class along with a `djarea` configuration:
|
||||
Add this mixin to any Django Form class along with a `mizan` configuration:
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
)
|
||||
@@ -197,10 +197,10 @@ class DjareaFormMixin:
|
||||
"""
|
||||
|
||||
# Configuration - subclasses must define this
|
||||
djarea: ClassVar[DjareaFormMeta]
|
||||
mizan: ClassVar[mizanFormMeta]
|
||||
|
||||
# Track registered forms to avoid duplicate registration
|
||||
_djarea_registered: ClassVar[bool] = False
|
||||
_mizan_registered: ClassVar[bool] = False
|
||||
|
||||
@classmethod
|
||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
||||
@@ -236,9 +236,7 @@ class DjareaFormMixin:
|
||||
return result
|
||||
return None
|
||||
|
||||
def on_submit_failure(
|
||||
self, request: HttpRequest, errors: "FormValidation"
|
||||
) -> None:
|
||||
def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
|
||||
"""
|
||||
Called after form validation fails.
|
||||
|
||||
@@ -250,23 +248,23 @@ class DjareaFormMixin:
|
||||
"""Auto-register when a concrete form class is defined."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
# Only register concrete forms with djarea config defined
|
||||
if _is_concrete_djarea_form(cls):
|
||||
# Only register concrete forms with mizan config defined
|
||||
if _is_concrete_mizan_form(cls):
|
||||
_register_form_as_server_functions(cls)
|
||||
|
||||
|
||||
def _is_concrete_djarea_form(cls: type) -> bool:
|
||||
def _is_concrete_mizan_form(cls: type) -> bool:
|
||||
"""
|
||||
Check if a class is a concrete Djarea form ready for registration.
|
||||
Check if a class is a concrete mizan form ready for registration.
|
||||
|
||||
A form is concrete if:
|
||||
1. It has a `djarea` attribute that is a DjareaFormMeta instance
|
||||
1. It has a `mizan` attribute that is a mizanFormMeta instance
|
||||
2. It inherits from Django's BaseForm
|
||||
3. It hasn't been registered yet (for this class definition)
|
||||
"""
|
||||
# Must have djarea config (check cls.__dict__ to avoid inheriting)
|
||||
djarea_config = cls.__dict__.get("djarea")
|
||||
if not isinstance(djarea_config, DjareaFormMeta):
|
||||
# Must have mizan config (check cls.__dict__ to avoid inheriting)
|
||||
mizan_config = cls.__dict__.get("mizan")
|
||||
if not isinstance(mizan_config, mizanFormMeta):
|
||||
return False
|
||||
|
||||
# Must be a Django form
|
||||
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
|
||||
return False
|
||||
|
||||
# Check if already registered (handle re-imports gracefully)
|
||||
if cls.__dict__.get("_djarea_registered", False):
|
||||
if cls.__dict__.get("_mizan_registered", False):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -282,7 +280,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
|
||||
|
||||
def _register_form_as_server_functions(form_class: type) -> None:
|
||||
"""
|
||||
Register a Django Form class as Djarea server functions.
|
||||
Register a Django Form class as mizan server functions.
|
||||
|
||||
Creates and registers:
|
||||
- {name}.schema - Returns form field definitions
|
||||
@@ -294,17 +292,20 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
from .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import validate_form_instance
|
||||
from djarea.setup.registry import register
|
||||
from djarea.client.function import ServerFunction
|
||||
from mizan.setup.registry import register
|
||||
from mizan.client.function import ServerFunction
|
||||
|
||||
config: DjareaFormMeta = form_class.djarea
|
||||
config: mizanFormMeta = form_class.mizan
|
||||
form_name = config.name
|
||||
|
||||
# Mark as registered
|
||||
form_class._djarea_registered = True
|
||||
form_class._mizan_registered = True
|
||||
|
||||
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact")
|
||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create FormDataSchema here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use lazy creation instead.
|
||||
@@ -346,7 +347,7 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
data=input.data if input else {},
|
||||
**init_kwargs,
|
||||
)
|
||||
# Override with DjareaFormMeta values
|
||||
# Override with mizanFormMeta values
|
||||
if config.title is not None:
|
||||
schema.title = config.title
|
||||
if config.subtitle is not None:
|
||||
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
|
||||
request = self.request
|
||||
|
||||
# Check if we have multipart data from executor
|
||||
if hasattr(request, "_djarea_form_data"):
|
||||
data = request._djarea_form_data
|
||||
files = request._djarea_form_files
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input is not None:
|
||||
# JSON input - already a dict
|
||||
data = input if isinstance(input, dict) else input.model_dump()
|
||||
@@ -474,17 +475,25 @@ def _register_formset_functions(
|
||||
"""Register formset server functions for a form."""
|
||||
from django.forms import formset_factory
|
||||
|
||||
from .schemas import FormsetSchema, FormsetSubmitFail, FormsetSubmitPass, FormsetValidation
|
||||
from .schemas import (
|
||||
FormsetSchema,
|
||||
FormsetSubmitFail,
|
||||
FormsetSubmitPass,
|
||||
FormsetValidation,
|
||||
)
|
||||
from .schema_utils import build_form_schema
|
||||
from .validation_utils import build_formset_validation
|
||||
from .formset_utils import forms_to_formset_post_data
|
||||
from djarea.setup.registry import register
|
||||
from djarea.client.function import ServerFunction
|
||||
from mizan.setup.registry import register
|
||||
from mizan.client.function import ServerFunction
|
||||
|
||||
formset_class = formset_factory(form_class)
|
||||
|
||||
# Generate PascalCase name for schemas
|
||||
pascal_name = ''.join(word.capitalize() for word in form_name.replace('.', '_').replace('-', '_').split('_'))
|
||||
pascal_name = "".join(
|
||||
word.capitalize()
|
||||
for word in form_name.replace(".", "_").replace("-", "_").split("_")
|
||||
)
|
||||
|
||||
# NOTE: We cannot create typed schemas here because form fields aren't
|
||||
# populated yet during __init_subclass__. We use generic dict inputs.
|
||||
@@ -506,7 +515,7 @@ def _register_formset_functions(
|
||||
"form": True,
|
||||
"form_name": form_name,
|
||||
"form_role": "formset_schema",
|
||||
}
|
||||
}
|
||||
|
||||
def call(self, input) -> FormsetSchema:
|
||||
init_kwargs = form_class.get_init_kwargs(self.request)
|
||||
@@ -590,10 +599,10 @@ def _register_formset_functions(
|
||||
init_kwargs = form_class.get_init_kwargs(request)
|
||||
|
||||
# Handle multipart vs JSON
|
||||
if hasattr(request, "_djarea_form_data"):
|
||||
post_data = request._djarea_form_data
|
||||
files = request._djarea_form_files
|
||||
elif input and hasattr(input, 'forms'):
|
||||
if hasattr(request, "_mizan_form_data"):
|
||||
post_data = request._mizan_form_data
|
||||
files = request._mizan_form_files
|
||||
elif input and hasattr(input, "forms"):
|
||||
# Input.forms is already a list of dicts
|
||||
forms_data = input.forms
|
||||
post_data = forms_to_formset_post_data(forms_data)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Djarea Allauth Integration
|
||||
mizan Allauth Integration
|
||||
|
||||
Backend support for django-allauth with Djarea server functions.
|
||||
Backend support for django-allauth with mizan server functions.
|
||||
|
||||
Provides:
|
||||
- Auth contexts (auth_status, user) - required by frontend allauth module
|
||||
@@ -11,8 +11,8 @@ Usage:
|
||||
# In your app's apps.py
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
import djarea.allauth.forms # noqa - registers forms
|
||||
import djarea.allauth.contexts # noqa - registers contexts
|
||||
import mizan.allauth.forms # noqa - registers forms
|
||||
import mizan.allauth.contexts # noqa - registers contexts
|
||||
"""
|
||||
|
||||
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Auth contexts for Djarea Allauth integration.
|
||||
Auth contexts for mizan Allauth integration.
|
||||
|
||||
These are the core auth primitives that the frontend allauth module depends on.
|
||||
Separated into two concerns:
|
||||
@@ -13,7 +13,7 @@ Both are registered as global contexts for SSR hydration.
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from mizan.client import client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -23,13 +23,14 @@ from djarea.client import client
|
||||
|
||||
class AuthStatusOutput(BaseModel):
|
||||
"""Authentication status and permission guards."""
|
||||
|
||||
is_authenticated: bool
|
||||
user_id: int | None = None
|
||||
is_staff: bool = False
|
||||
is_superuser: bool = False
|
||||
|
||||
|
||||
@client(context='global')
|
||||
@client(context="global")
|
||||
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
||||
"""
|
||||
Auth status context - provides authentication state and guards.
|
||||
@@ -62,13 +63,14 @@ def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
"""Full user profile data."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
|
||||
|
||||
@client(context='global')
|
||||
@client(context="global")
|
||||
def user(request: HttpRequest) -> UserOutput | None:
|
||||
"""
|
||||
User profile context - provides full user data.
|
||||
@@ -90,17 +92,18 @@ def user(request: HttpRequest) -> UserOutput | None:
|
||||
return None
|
||||
|
||||
# Check if we have full user data or just JWT claims
|
||||
if hasattr(req_user, 'email') and req_user.email:
|
||||
if hasattr(req_user, "email") and req_user.email:
|
||||
# Full User object (session auth)
|
||||
return UserOutput(
|
||||
id=req_user.id,
|
||||
email=req_user.email,
|
||||
first_name=getattr(req_user, 'first_name', '') or '',
|
||||
last_name=getattr(req_user, 'last_name', '') or '',
|
||||
first_name=getattr(req_user, "first_name", "") or "",
|
||||
last_name=getattr(req_user, "last_name", "") or "",
|
||||
)
|
||||
|
||||
# JWTUser - need to fetch from DB
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
try:
|
||||
@@ -108,8 +111,8 @@ def user(request: HttpRequest) -> UserOutput | None:
|
||||
return UserOutput(
|
||||
id=db_user.id,
|
||||
email=db_user.email,
|
||||
first_name=db_user.first_name or '',
|
||||
last_name=db_user.last_name or '',
|
||||
first_name=db_user.first_name or "",
|
||||
last_name=db_user.last_name or "",
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Allauth forms as Djarea server functions.
|
||||
Allauth forms as mizan server functions.
|
||||
|
||||
This module wraps allauth forms with DjareaFormMixin, exposing them as
|
||||
This module wraps allauth forms with mizanFormMixin, exposing them as
|
||||
typed server functions for the React frontend.
|
||||
|
||||
Each form becomes three server functions:
|
||||
@@ -13,7 +13,7 @@ Import this module in your app's ready() to register the forms:
|
||||
|
||||
class MyAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
import djarea.allauth.forms # noqa
|
||||
import mizan.allauth.forms # noqa
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
# Account forms
|
||||
from allauth.account.forms import (
|
||||
@@ -41,6 +41,7 @@ from allauth.account.forms import (
|
||||
# Password reauthentication form - conditionally import
|
||||
try:
|
||||
from allauth.account.forms import ReauthenticateForm
|
||||
|
||||
HAS_REAUTH = True
|
||||
except ImportError:
|
||||
HAS_REAUTH = False
|
||||
@@ -51,6 +52,7 @@ try:
|
||||
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
||||
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
||||
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
||||
|
||||
HAS_MFA = True
|
||||
except ImportError:
|
||||
HAS_MFA = False
|
||||
@@ -58,22 +60,24 @@ except ImportError:
|
||||
# WebAuthn forms (if available)
|
||||
try:
|
||||
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
||||
|
||||
HAS_WEBAUTHN = True
|
||||
except ImportError:
|
||||
HAS_WEBAUTHN = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from djarea.forms.schemas import FormValidation
|
||||
from mizan.forms.schemas import FormValidation
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Account Forms
|
||||
# =============================================================================
|
||||
|
||||
class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
||||
|
||||
class mizanLoginForm(LoginForm, mizanFormMixin):
|
||||
"""Sign in with email and password."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="login",
|
||||
title="Sign In",
|
||||
subtitle="Welcome back. Enter your credentials to continue.",
|
||||
@@ -90,10 +94,10 @@ class DjareaLoginForm(LoginForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
||||
class mizanSignupForm(SignupForm, mizanFormMixin):
|
||||
"""Create a new account."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="signup",
|
||||
title="Create Account",
|
||||
subtitle="Enter your details to get started.",
|
||||
@@ -109,10 +113,10 @@ class DjareaSignupForm(SignupForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
||||
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
|
||||
"""Add another email address to your account."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="add_email",
|
||||
title="Add Email Address",
|
||||
subtitle="Add another email address to your account.",
|
||||
@@ -128,10 +132,10 @@ class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
||||
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
|
||||
"""Change your account password."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="change_password",
|
||||
title="Change Password",
|
||||
subtitle="Update your password to keep your account secure.",
|
||||
@@ -147,10 +151,10 @@ class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
||||
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
|
||||
"""Set a password for accounts created via social login."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="set_password",
|
||||
title="Set Password",
|
||||
subtitle="Create a password for your account.",
|
||||
@@ -166,10 +170,10 @@ class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
||||
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
|
||||
"""Request a password reset email."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="reset_password",
|
||||
title="Reset Password",
|
||||
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
||||
@@ -185,10 +189,10 @@ class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
||||
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
|
||||
"""Set a new password using a reset key."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="reset_password_from_key",
|
||||
title="Set New Password",
|
||||
subtitle="Enter your new password below.",
|
||||
@@ -204,10 +208,10 @@ class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
||||
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
|
||||
"""Request a login code via email."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="request_login_code",
|
||||
title="Sign In with Code",
|
||||
subtitle="Enter your email address and we'll send you a login code.",
|
||||
@@ -223,10 +227,10 @@ class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
||||
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
|
||||
"""Confirm a login code."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="confirm_login_code",
|
||||
title="Enter Code",
|
||||
subtitle="Enter the code we sent to your email.",
|
||||
@@ -242,10 +246,10 @@ class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
|
||||
return None
|
||||
|
||||
|
||||
class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
||||
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
|
||||
"""Verify an email with a token."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="user_token",
|
||||
title="Verify Email",
|
||||
subtitle="Enter the verification code from your email.",
|
||||
@@ -263,10 +267,11 @@ class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
|
||||
|
||||
# Password reauthentication - conditionally define
|
||||
if HAS_REAUTH:
|
||||
class DjareaReauthenticateForm(ReauthenticateForm, DjareaFormMixin):
|
||||
|
||||
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
|
||||
"""Re-authenticate with password for sensitive actions."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="reauthenticate",
|
||||
title="Confirm Your Identity",
|
||||
subtitle="Please enter your password to continue.",
|
||||
@@ -280,6 +285,7 @@ if HAS_REAUTH:
|
||||
|
||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
||||
from allauth.account.internal.flows import reauthentication
|
||||
|
||||
reauthentication.reauthenticate_by_password(request)
|
||||
return None
|
||||
|
||||
@@ -289,10 +295,11 @@ if HAS_REAUTH:
|
||||
# =============================================================================
|
||||
|
||||
if HAS_MFA:
|
||||
class DjareaMFAAuthenticateForm(MFAAuthenticateForm, DjareaFormMixin):
|
||||
|
||||
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
|
||||
"""Authenticate with MFA during login."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="mfa_authenticate",
|
||||
title="Two-Factor Authentication",
|
||||
subtitle="Enter your authentication code to continue.",
|
||||
@@ -307,10 +314,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaMFAReauthenticateForm(MFAReauthenticateForm, DjareaFormMixin):
|
||||
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
|
||||
"""Re-authenticate with MFA for sensitive actions."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="mfa_reauthenticate",
|
||||
title="Confirm Your Identity",
|
||||
subtitle="Enter your authentication code to continue.",
|
||||
@@ -325,10 +332,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaActivateTOTPForm(ActivateTOTPForm, DjareaFormMixin):
|
||||
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
|
||||
"""Activate TOTP authenticator."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="activate_totp",
|
||||
title="Set Up Authenticator",
|
||||
subtitle="Enter the code from your authenticator app to complete setup.",
|
||||
@@ -343,10 +350,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaDeactivateTOTPForm(DeactivateTOTPForm, DjareaFormMixin):
|
||||
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
|
||||
"""Deactivate TOTP authenticator."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="deactivate_totp",
|
||||
title="Disable Authenticator",
|
||||
subtitle="Enter your password to disable two-factor authentication.",
|
||||
@@ -361,10 +368,10 @@ if HAS_MFA:
|
||||
self.save()
|
||||
return None
|
||||
|
||||
class DjareaGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, DjareaFormMixin):
|
||||
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
|
||||
"""Generate new recovery codes."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="generate_recovery_codes",
|
||||
title="Recovery Codes",
|
||||
subtitle="Generate new recovery codes for your account.",
|
||||
@@ -381,10 +388,11 @@ if HAS_MFA:
|
||||
|
||||
|
||||
if HAS_WEBAUTHN:
|
||||
class DjareaAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, DjareaFormMixin):
|
||||
|
||||
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
|
||||
"""Authenticate with WebAuthn security key."""
|
||||
|
||||
djarea = DjareaFormMeta(
|
||||
mizan = mizanFormMeta(
|
||||
name="webauthn_authenticate",
|
||||
title="Security Key",
|
||||
subtitle="Use your security key to authenticate.",
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
djarea.jwt - JWT authentication for server functions.
|
||||
mizan.jwt - JWT authentication for server functions.
|
||||
|
||||
Provides:
|
||||
- Server functions for obtaining/refreshing JWT tokens
|
||||
@@ -10,10 +10,10 @@ Server Functions:
|
||||
- jwt_refresh: Refresh tokens using a refresh token
|
||||
|
||||
Usage in apps.py or urls.py (to register the functions):
|
||||
import djarea.jwt.functions # noqa: F401
|
||||
import mizan.jwt.functions # noqa: F401
|
||||
|
||||
Note: This module is purpose-built for Djarea server functions.
|
||||
For Django Ninja API authentication, use djarea.jwt.security directly.
|
||||
Note: This module is purpose-built for mizan server functions.
|
||||
For Django Ninja API authentication, use mizan.jwt.security directly.
|
||||
"""
|
||||
|
||||
# Server functions (import to register with @client decorator)
|
||||
@@ -36,12 +36,13 @@ from .settings import get_settings, JWTSettings
|
||||
|
||||
# Security (Ninja API auth) - lazy import to avoid triggering
|
||||
# django-ninja's settings access at module load time.
|
||||
# Use: from djarea.jwt.security import jwt_auth
|
||||
# Use: from mizan.jwt.security import jwt_auth
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ("JWTAuth", "jwt_auth"):
|
||||
from .security import JWTAuth, jwt_auth
|
||||
|
||||
globals()["JWTAuth"] = JWTAuth
|
||||
globals()["jwt_auth"] = jwt_auth
|
||||
return globals()[name]
|
||||
@@ -1,19 +1,20 @@
|
||||
"""
|
||||
JWT Server Functions
|
||||
|
||||
JWT token operations exposed as djarea server functions.
|
||||
JWT token operations exposed as mizan server functions.
|
||||
Works over WebSocket RPC (primary) or HTTP fallback.
|
||||
"""
|
||||
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from djarea.jwt.tokens import create_token_pair, refresh_tokens
|
||||
from mizan.client import client
|
||||
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
||||
|
||||
|
||||
class TokenPairOutput(BaseModel):
|
||||
"""JWT token pair response."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
@@ -21,6 +22,7 @@ class TokenPairOutput(BaseModel):
|
||||
|
||||
class JWTError(BaseModel):
|
||||
"""JWT operation error."""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
@@ -45,10 +47,12 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
||||
raise PermissionError("Authentication required")
|
||||
|
||||
# Get session key - for WebSocket, this comes from the scope
|
||||
session = getattr(request, 'session', None)
|
||||
session = getattr(request, "session", None)
|
||||
if session is None:
|
||||
# WebSocket request adapter - session is a dict, not SessionBase
|
||||
session_key = getattr(request, '_scope', {}).get('session', {}).get('_session_key')
|
||||
session_key = (
|
||||
getattr(request, "_scope", {}).get("session", {}).get("_session_key")
|
||||
)
|
||||
if not session_key:
|
||||
raise PermissionError("No session available")
|
||||
else:
|
||||
@@ -61,8 +65,8 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
|
||||
tokens = create_token_pair(
|
||||
user.pk,
|
||||
session_key,
|
||||
is_staff=getattr(user, 'is_staff', False),
|
||||
is_superuser=getattr(user, 'is_superuser', False),
|
||||
is_staff=getattr(user, "is_staff", False),
|
||||
is_superuser=getattr(user, "is_superuser", False),
|
||||
)
|
||||
|
||||
return TokenPairOutput(
|
||||
@@ -25,7 +25,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from djarea.channels import get_channels_openapi_schema
|
||||
from mizan.channels import get_channels_openapi_schema
|
||||
|
||||
schema = get_channels_openapi_schema()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
Export Djarea Schema
|
||||
Export mizan Schema
|
||||
|
||||
Management command to export the djarea OpenAPI schema for TypeScript code generation.
|
||||
Management command to export the mizan OpenAPI schema for TypeScript code generation.
|
||||
The schema is consumed by openapi-typescript for robust type generation.
|
||||
|
||||
Usage:
|
||||
python manage.py export_djarea_schema # Output to stdout
|
||||
python manage.py export_djarea_schema --output schema.json # Output to file
|
||||
python manage.py export_mizan_schema # Output to stdout
|
||||
python manage.py export_mizan_schema --output schema.json # Output to file
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -14,11 +14,11 @@ from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from djarea.export import generate_openapi_schema
|
||||
from mizan.export import generate_openapi_schema
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Export djarea OpenAPI schema for TypeScript code generation"
|
||||
help = "Export mizan OpenAPI schema for TypeScript code generation"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -44,8 +44,6 @@ class Command(BaseCommand):
|
||||
output_path = Path(options["output"])
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json_output)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Schema written to {output_path}")
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Schema written to {output_path}"))
|
||||
else:
|
||||
self.stdout.write(json_output)
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
djarea.setup - Integration and registration utilities.
|
||||
mizan.setup - Integration and registration utilities.
|
||||
|
||||
This subpackage contains everything developers need to integrate Djarea:
|
||||
This subpackage contains everything developers need to integrate mizan:
|
||||
- Registry for server functions and channels
|
||||
- Auto-discovery for apps
|
||||
- Configuration settings
|
||||
|
||||
Usage:
|
||||
from djarea.setup import djarea_clients, register, get_function
|
||||
from mizan.setup import mizan_clients, register, get_function
|
||||
"""
|
||||
|
||||
from .registry import (
|
||||
@@ -30,12 +30,12 @@ from .registry import (
|
||||
)
|
||||
|
||||
from .discovery import (
|
||||
djarea_clients,
|
||||
djarea_module,
|
||||
mizan_clients,
|
||||
mizan_module,
|
||||
)
|
||||
|
||||
from .settings import (
|
||||
DjareaSettings,
|
||||
mizanSettings,
|
||||
get_settings,
|
||||
clear_settings_cache,
|
||||
)
|
||||
@@ -60,10 +60,10 @@ __all__ = [
|
||||
"get_forms",
|
||||
"clear_registry",
|
||||
# Discovery
|
||||
"djarea_clients",
|
||||
"djarea_module",
|
||||
"mizan_clients",
|
||||
"mizan_module",
|
||||
# Settings
|
||||
"DjareaSettings",
|
||||
"mizanSettings",
|
||||
"get_settings",
|
||||
"clear_settings_cache",
|
||||
]
|
||||
@@ -1,25 +1,25 @@
|
||||
"""
|
||||
Djarea Auto-Discovery
|
||||
mizan Auto-Discovery
|
||||
|
||||
Scans Django apps for server functions following the 'clients' layer convention:
|
||||
- <app>/clients.py
|
||||
- <app>/clients/**/*.py
|
||||
|
||||
Usage in urls.py:
|
||||
from djarea.setup.discovery import djarea_clients
|
||||
from mizan.setup.discovery import mizan_clients
|
||||
|
||||
djarea_clients('apps') # Scans apps/*/clients.py
|
||||
djarea_clients('djarea', 'allauth') # Scans djarea/allauth/**/*.py
|
||||
mizan_clients('apps') # Scans apps/*/clients.py
|
||||
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
|
||||
|
||||
This replaces manual "import to register" patterns with explicit auto-discovery.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from djarea._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||
|
||||
from .registry import register, get_function
|
||||
from djarea.client.function import ServerFunction
|
||||
from mizan.client.function import ServerFunction
|
||||
|
||||
|
||||
class _RegisterServerFunctions:
|
||||
@@ -35,10 +35,10 @@ class _RegisterServerFunctions:
|
||||
isinstance(member, type)
|
||||
and issubclass(member, ServerFunction)
|
||||
and member is not ServerFunction
|
||||
and hasattr(member, '__name__')
|
||||
and hasattr(member, "__name__")
|
||||
):
|
||||
# Use the function name as registration name
|
||||
fn_name = getattr(member, 'name', None) or member.__name__
|
||||
fn_name = getattr(member, "name", None) or member.__name__
|
||||
|
||||
# Skip already registered (idempotent)
|
||||
if get_function(fn_name) is member:
|
||||
@@ -51,7 +51,7 @@ class _RegisterServerFunctions:
|
||||
pass
|
||||
|
||||
|
||||
def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
||||
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||
"""
|
||||
Discover and register server functions from Django apps.
|
||||
|
||||
@@ -65,26 +65,26 @@ def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
|
||||
|
||||
Example:
|
||||
# In urls.py
|
||||
djarea_clients('apps') # Scans apps/*/clients.py
|
||||
djarea_clients('apps', 'functions') # Scans apps/*/functions.py
|
||||
mizan_clients('apps') # Scans apps/*/clients.py
|
||||
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
|
||||
"""
|
||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||
visitor.visit(_RegisterServerFunctions())
|
||||
|
||||
|
||||
def djarea_module(module_path: str) -> None:
|
||||
def mizan_module(module_path: str) -> None:
|
||||
"""
|
||||
Register server functions from a specific module.
|
||||
|
||||
Use this for library modules that don't follow the app convention.
|
||||
|
||||
Args:
|
||||
module_path: Full module path (e.g., 'djarea.integrations.allauth')
|
||||
module_path: Full module path (e.g., 'mizan.integrations.allauth')
|
||||
|
||||
Example:
|
||||
djarea_module('djarea.integrations.allauth')
|
||||
djarea_module('djarea.jwt.functions')
|
||||
mizan_module('mizan.integrations.allauth')
|
||||
mizan_module('mizan.jwt.functions')
|
||||
"""
|
||||
members = get_members(module_path)
|
||||
handler = _RegisterServerFunctions()
|
||||
handler.on_module('', [], members)
|
||||
handler.on_module("", [], members)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Registry
|
||||
mizan Registry
|
||||
|
||||
Central registration for server functions, channels, and compositions.
|
||||
All items are identified by name.
|
||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from djarea.client.function import ServerFunction, ComposedContext
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.client.function import ServerFunction, ComposedContext
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
|
||||
# Global registries - all use name as key
|
||||
@@ -34,8 +34,8 @@ def register(
|
||||
Returns:
|
||||
The view class (allows use as part of decorator chain)
|
||||
"""
|
||||
from djarea.client.function import ServerFunction
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.client.function import ServerFunction
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
view_class.name = name
|
||||
|
||||
@@ -98,7 +98,7 @@ def register_form(
|
||||
Usage:
|
||||
register_form(ContactForm, 'contact', submit_handler=handle_contact)
|
||||
"""
|
||||
from djarea.client.function import create_form_functions
|
||||
from mizan.client.function import create_form_functions
|
||||
|
||||
schema_fn, validate_fn, submit_fn = create_form_functions(
|
||||
form_class, name, submit_handler
|
||||
@@ -130,9 +130,7 @@ def register_compose(
|
||||
# Same composition being re-registered (reload scenario)
|
||||
_compositions[name] = composed
|
||||
return composed
|
||||
raise ValueError(
|
||||
f"Composition '{name}' already registered by {existing.name}"
|
||||
)
|
||||
raise ValueError(f"Composition '{name}' already registered by {existing.name}")
|
||||
_compositions[name] = composed
|
||||
return composed
|
||||
|
||||
@@ -254,17 +252,21 @@ def get_schema() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
# Extract Params schema (only if defined)
|
||||
if hasattr(channel_class, 'Params') and channel_class.Params:
|
||||
if hasattr(channel_class, "Params") and channel_class.Params:
|
||||
channel_schema["params"] = channel_class.Params.model_json_schema()
|
||||
|
||||
# Extract ReactMessage schema (only if defined - indicates bidirectional)
|
||||
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage:
|
||||
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema()
|
||||
if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
|
||||
channel_schema[
|
||||
"react_message"
|
||||
] = channel_class.ReactMessage.model_json_schema()
|
||||
channel_schema["bidirectional"] = True
|
||||
|
||||
# Extract DjangoMessage schema (only if defined)
|
||||
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage:
|
||||
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema()
|
||||
if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
|
||||
channel_schema[
|
||||
"django_message"
|
||||
] = channel_class.DjangoMessage.model_json_schema()
|
||||
|
||||
channels_schema[name] = channel_schema
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Djarea Settings
|
||||
mizan Settings
|
||||
|
||||
Configuration is read from Django settings with sensible defaults.
|
||||
"""
|
||||
@@ -11,23 +11,23 @@ from django.conf import settings as django_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class DjareaSettings:
|
||||
"""Djarea configuration."""
|
||||
class mizanSettings:
|
||||
"""mizan configuration."""
|
||||
|
||||
# Whether to expose function names in DEBUG mode errors
|
||||
debug_expose_names: bool
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> DjareaSettings:
|
||||
def get_settings() -> mizanSettings:
|
||||
"""
|
||||
Load Djarea settings from Django settings.
|
||||
Load mizan settings from Django settings.
|
||||
|
||||
Settings:
|
||||
DJAREA_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
|
||||
mizan_DEBUG_EXPOSE_NAMES: Show function names in errors when DEBUG=True (default: True)
|
||||
"""
|
||||
return DjareaSettings(
|
||||
debug_expose_names=getattr(django_settings, "DJAREA_DEBUG_EXPOSE_NAMES", True),
|
||||
return mizanSettings(
|
||||
debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
|
||||
)
|
||||
|
||||
|
||||
3
django/src/mizan/shapes/__init__.py
Normal file
3
django/src/mizan/shapes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from mizan.shapes.core import Diff, NestedDiff, Shape
|
||||
|
||||
__all__ = ["Diff", "NestedDiff", "Shape"]
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Authentication Tests for Djarea Server Functions
|
||||
Authentication Tests for mizan Server Functions
|
||||
|
||||
Tests all combinations of:
|
||||
- Transport: HTTP vs WebSocket RPC
|
||||
@@ -19,20 +19,20 @@ from django.contrib.sessions.backends.db import SessionStore
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
|
||||
from djarea.jwt.tokens import (
|
||||
from mizan.jwt.tokens import (
|
||||
create_token_pair,
|
||||
decode_token,
|
||||
JWTUser,
|
||||
)
|
||||
from djarea.client.executor import (
|
||||
from mizan.client.executor import (
|
||||
_try_jwt_auth,
|
||||
execute_function,
|
||||
FunctionError,
|
||||
FunctionResult,
|
||||
ErrorCode,
|
||||
)
|
||||
from djarea.client import client
|
||||
from djarea.setup.registry import clear_registry, register
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import clear_registry, register
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ User = get_user_model()
|
||||
# Test Output Models (proper Pydantic models, not raw dicts)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class WhoamiOutput(BaseModel):
|
||||
is_authenticated: bool
|
||||
user_id: int | None
|
||||
@@ -62,6 +63,7 @@ class UserTypeOutput(BaseModel):
|
||||
# Test Server Functions - defined as plain functions, registered in setUp
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _whoami_fn(request) -> WhoamiOutput:
|
||||
"""Returns info about the authenticated user."""
|
||||
user = request.user
|
||||
@@ -104,6 +106,7 @@ class HTTPAuthTests(TestCase):
|
||||
user_type=type(user).__name__,
|
||||
is_staff=getattr(user, "is_staff", False),
|
||||
)
|
||||
|
||||
register(whoami, "whoami")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -168,7 +171,7 @@ class HTTPAuthTests(TestCase):
|
||||
def test_jwt_expired_with_session(self):
|
||||
"""Expired JWT with valid session → Reject (do NOT fall back)."""
|
||||
# Create token with past expiration by mocking time
|
||||
with patch("djarea.jwt.tokens.time.time", return_value=0):
|
||||
with patch("mizan.jwt.tokens.time.time", return_value=0):
|
||||
tokens = create_token_pair(
|
||||
self.user.pk,
|
||||
self.session_key,
|
||||
@@ -248,7 +251,7 @@ class JWTUserTests(TestCase):
|
||||
|
||||
def test_jwt_user_attributes(self):
|
||||
"""JWTUser has expected attributes."""
|
||||
from djarea.jwt.tokens import TokenPayload
|
||||
from mizan.jwt.tokens import TokenPayload
|
||||
|
||||
payload = TokenPayload(
|
||||
user_id=42,
|
||||
@@ -272,7 +275,7 @@ class JWTUserTests(TestCase):
|
||||
|
||||
def test_jwt_user_string_id(self):
|
||||
"""JWTUser handles string user_id (converted to int)."""
|
||||
from djarea.jwt.tokens import TokenPayload
|
||||
from mizan.jwt.tokens import TokenPayload
|
||||
|
||||
payload = TokenPayload(
|
||||
user_id="42", # String, as stored in JWT
|
||||
@@ -333,6 +336,7 @@ class AuthDecoratorTests(TestCase):
|
||||
@client(auth=True)
|
||||
def protected_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(protected_fn, "protected_fn")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -345,9 +349,11 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_required_with_authenticated(self):
|
||||
"""@client(auth=True) allows authenticated users."""
|
||||
|
||||
@client(auth=True)
|
||||
def protected_fn2(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(protected_fn2, "protected_fn2")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -360,9 +366,11 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_staff_with_regular_user(self):
|
||||
"""@client(auth='staff') rejects non-staff users."""
|
||||
@client(auth='staff')
|
||||
|
||||
@client(auth="staff")
|
||||
def staff_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(staff_fn, "staff_fn")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -375,9 +383,11 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_staff_with_staff_user(self):
|
||||
"""@client(auth='staff') allows staff users."""
|
||||
@client(auth='staff')
|
||||
|
||||
@client(auth="staff")
|
||||
def staff_fn2(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(staff_fn2, "staff_fn2")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -389,9 +399,11 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_superuser_with_staff(self):
|
||||
"""@client(auth='superuser') rejects non-superusers."""
|
||||
@client(auth='superuser')
|
||||
|
||||
@client(auth="superuser")
|
||||
def super_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(super_fn, "super_fn")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -404,9 +416,11 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_superuser_with_superuser(self):
|
||||
"""@client(auth='superuser') allows superusers."""
|
||||
@client(auth='superuser')
|
||||
|
||||
@client(auth="superuser")
|
||||
def super_fn2(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(super_fn2, "super_fn2")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -418,11 +432,12 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_with_jwt_user(self):
|
||||
"""Auth checks work with JWTUser (stateless)."""
|
||||
from djarea.jwt.tokens import TokenPayload
|
||||
from mizan.jwt.tokens import TokenPayload
|
||||
|
||||
@client(auth='staff')
|
||||
@client(auth="staff")
|
||||
def jwt_staff_fn(request) -> UserTypeOutput:
|
||||
return UserTypeOutput(user_type=type(request.user).__name__)
|
||||
|
||||
register(jwt_staff_fn, "jwt_staff_fn")
|
||||
|
||||
# Create JWTUser with is_staff=True
|
||||
@@ -448,7 +463,8 @@ class AuthDecoratorTests(TestCase):
|
||||
def test_auth_invalid_string_raises(self):
|
||||
"""Invalid auth string raises ValueError at decoration time."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
@client(auth='admin') # 'admin' is not valid
|
||||
|
||||
@client(auth="admin") # 'admin' is not valid
|
||||
def bad_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
@@ -457,9 +473,11 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_callable_returns_true(self):
|
||||
"""Callable auth returning True allows access."""
|
||||
@client(auth=lambda r: r.user.email.endswith('@example.com'))
|
||||
|
||||
@client(auth=lambda r: r.user.email.endswith("@example.com"))
|
||||
def email_check_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(email_check_fn, "email_check_fn")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -472,9 +490,11 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_callable_returns_false(self):
|
||||
"""Callable auth returning False denies access."""
|
||||
@client(auth=lambda r: r.user.email.endswith('@admin.com'))
|
||||
|
||||
@client(auth=lambda r: r.user.email.endswith("@admin.com"))
|
||||
def admin_email_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(admin_email_fn, "admin_email_fn")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -488,14 +508,16 @@ class AuthDecoratorTests(TestCase):
|
||||
|
||||
def test_auth_callable_raises_permission_error(self):
|
||||
"""Callable auth raising PermissionError uses custom message."""
|
||||
|
||||
def check_premium(request):
|
||||
if not getattr(request.user, 'is_premium', False):
|
||||
if not getattr(request.user, "is_premium", False):
|
||||
raise PermissionError("Premium subscription required")
|
||||
return True
|
||||
|
||||
@client(auth=check_premium)
|
||||
def premium_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(premium_fn, "premium_fn")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -519,6 +541,7 @@ class AuthDecoratorTests(TestCase):
|
||||
@client(auth=must_be_authenticated)
|
||||
def needs_login_fn(request) -> OkOutput:
|
||||
return OkOutput(ok=True)
|
||||
|
||||
register(needs_login_fn, "needs_login_fn")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -5,7 +5,7 @@ Compares performance of HTTP POST vs WebSocket RPC for server function calls.
|
||||
Includes realistic scenarios with ORM queries.
|
||||
|
||||
Usage:
|
||||
python manage.py test djarea.tests.test_benchmarks --verbosity=2
|
||||
python manage.py test mizan.tests.test_benchmarks --verbosity=2
|
||||
|
||||
Note:
|
||||
These are not unit tests - they measure performance. Results are printed
|
||||
@@ -26,9 +26,9 @@ from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client.executor import FunctionResult, execute_function, function_call_view
|
||||
from djarea.setup.registry import clear_registry
|
||||
from djarea.client import client
|
||||
from mizan.client.executor import FunctionResult, execute_function, function_call_view
|
||||
from mizan.setup.registry import clear_registry
|
||||
from mizan.client import client
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
@@ -66,7 +66,7 @@ class StatsOutput(BaseModel):
|
||||
|
||||
def setup_benchmark_functions():
|
||||
"""Register benchmark server functions."""
|
||||
from djarea.setup.registry import register
|
||||
from mizan.setup.registry import register
|
||||
|
||||
clear_registry()
|
||||
|
||||
@@ -75,6 +75,7 @@ def setup_benchmark_functions():
|
||||
def bench_simple(request: HttpRequest, a: int, b: int) -> SimpleOutput:
|
||||
"""Simple addition - baseline with no I/O."""
|
||||
return SimpleOutput(value=a + b)
|
||||
|
||||
register(bench_simple, "bench_simple")
|
||||
|
||||
# 2. Single ORM query
|
||||
@@ -85,6 +86,7 @@ def setup_benchmark_functions():
|
||||
if user:
|
||||
return UserOutput(id=user.id, email=user.email)
|
||||
return UserOutput(id=0, email="")
|
||||
|
||||
register(bench_get_user, "bench_get_user")
|
||||
|
||||
# 3. List query with limit
|
||||
@@ -96,6 +98,7 @@ def setup_benchmark_functions():
|
||||
users=[{"id": u.id, "email": u.email} for u in users],
|
||||
count=len(users),
|
||||
)
|
||||
|
||||
register(bench_list_users, "bench_list_users")
|
||||
|
||||
# 4. Aggregation query
|
||||
@@ -110,11 +113,14 @@ def setup_benchmark_functions():
|
||||
active_users=active,
|
||||
staff_count=staff,
|
||||
)
|
||||
|
||||
register(bench_user_stats, "bench_user_stats")
|
||||
|
||||
# 5. Complex query with joins
|
||||
@client
|
||||
def bench_user_search(request: HttpRequest, email_contains: str, limit: int) -> UserListOutput:
|
||||
def bench_user_search(
|
||||
request: HttpRequest, email_contains: str, limit: int
|
||||
) -> UserListOutput:
|
||||
"""Search users by email pattern."""
|
||||
users = User.objects.filter(
|
||||
email__icontains=email_contains,
|
||||
@@ -124,6 +130,7 @@ def setup_benchmark_functions():
|
||||
users=[{"id": u.id, "email": u.email} for u in users],
|
||||
count=len(users),
|
||||
)
|
||||
|
||||
register(bench_user_search, "bench_user_search")
|
||||
|
||||
|
||||
@@ -158,11 +165,13 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
# Create 100 test users
|
||||
users = []
|
||||
for i in range(100):
|
||||
users.append(User(
|
||||
email=f"bench{i}@example.com",
|
||||
is_active=i % 10 != 0, # 90% active
|
||||
is_staff=i < 5, # 5 staff
|
||||
))
|
||||
users.append(
|
||||
User(
|
||||
email=f"bench{i}@example.com",
|
||||
is_active=i % 10 != 0, # 90% active
|
||||
is_staff=i < 5, # 5 staff
|
||||
)
|
||||
)
|
||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||
self.test_user = User.objects.first()
|
||||
|
||||
@@ -170,12 +179,12 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
"""Create a request with optional JSON body."""
|
||||
if body:
|
||||
request = self.factory.post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
data=json.dumps(body),
|
||||
content_type="application/json",
|
||||
)
|
||||
else:
|
||||
request = self.factory.post("/api/djarea/call/")
|
||||
request = self.factory.post("/api/mizan/call/")
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
return request
|
||||
@@ -245,12 +254,16 @@ class ProtocolBenchmark(TransactionTestCase):
|
||||
print(f"{'Benchmark':<40} {'Mean':>8} {'Median':>8} {'P95':>8} {'P99':>8}")
|
||||
print("=" * 80)
|
||||
for r in results:
|
||||
print(f"{r['label']:<40} {r['mean']:>7.3f}ms {r['median']:>7.3f}ms {r['p95']:>7.3f}ms {r['p99']:>7.3f}ms")
|
||||
print(
|
||||
f"{r['label']:<40} {r['mean']:>7.3f}ms {r['median']:>7.3f}ms {r['p95']:>7.3f}ms {r['p99']:>7.3f}ms"
|
||||
)
|
||||
print("=" * 80)
|
||||
|
||||
def _print_comparison(self, executor_stats: dict, http_stats: dict):
|
||||
"""Print comparison between executor and HTTP."""
|
||||
overhead = ((http_stats["mean"] - executor_stats["mean"]) / executor_stats["mean"]) * 100
|
||||
overhead = (
|
||||
(http_stats["mean"] - executor_stats["mean"]) / executor_stats["mean"]
|
||||
) * 100
|
||||
print(f" HTTP overhead vs Executor: {overhead:+.1f}%")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -400,18 +413,20 @@ class ThroughputBenchmark(TransactionTestCase):
|
||||
"""Create test users for benchmarks."""
|
||||
users = []
|
||||
for i in range(100):
|
||||
users.append(User(
|
||||
email=f"bench{i}@example.com",
|
||||
is_active=i % 10 != 0,
|
||||
is_staff=i < 5,
|
||||
))
|
||||
users.append(
|
||||
User(
|
||||
email=f"bench{i}@example.com",
|
||||
is_active=i % 10 != 0,
|
||||
is_staff=i < 5,
|
||||
)
|
||||
)
|
||||
User.objects.bulk_create(users, ignore_conflicts=True)
|
||||
self.test_user = User.objects.first()
|
||||
|
||||
def _make_request(self, body: dict) -> HttpRequest:
|
||||
"""Create a POST request with JSON body."""
|
||||
request = self.factory.post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
data=json.dumps(body),
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -470,7 +485,9 @@ class ThroughputBenchmark(TransactionTestCase):
|
||||
"""Throughput: Simple computation (no I/O)."""
|
||||
print("\n\n### THROUGHPUT: Simple Computation ###")
|
||||
|
||||
executor_rps = self._measure_throughput_executor("bench_simple", {"a": 1, "b": 2})
|
||||
executor_rps = self._measure_throughput_executor(
|
||||
"bench_simple", {"a": 1, "b": 2}
|
||||
)
|
||||
http_rps = self._measure_throughput_http("bench_simple", {"a": 1, "b": 2})
|
||||
|
||||
self._print_throughput("Simple (no I/O)", executor_rps, http_rps)
|
||||
@@ -502,7 +519,9 @@ class ThroughputBenchmark(TransactionTestCase):
|
||||
"""Throughput: List query."""
|
||||
print("\n\n### THROUGHPUT: List Query (10 users) ###")
|
||||
|
||||
executor_rps = self._measure_throughput_executor("bench_list_users", {"limit": 10})
|
||||
executor_rps = self._measure_throughput_executor(
|
||||
"bench_list_users", {"limit": 10}
|
||||
)
|
||||
http_rps = self._measure_throughput_http("bench_list_users", {"limit": 10})
|
||||
|
||||
self._print_throughput("List Query", executor_rps, http_rps)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for djarea.channels module.
|
||||
Tests for mizan.channels module.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -8,7 +8,7 @@ from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.channels import (
|
||||
from mizan.channels import (
|
||||
ReactChannel,
|
||||
register,
|
||||
get_channel,
|
||||
@@ -25,8 +25,10 @@ User = get_user_model()
|
||||
# Test Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MockUser:
|
||||
"""Mock user for testing."""
|
||||
|
||||
def __init__(self, is_authenticated=True, email="test@example.com"):
|
||||
self.is_authenticated = is_authenticated
|
||||
self.email = email
|
||||
@@ -34,6 +36,7 @@ class MockUser:
|
||||
|
||||
class MockAnonymousUser:
|
||||
"""Mock anonymous user."""
|
||||
|
||||
is_authenticated = False
|
||||
email = ""
|
||||
|
||||
@@ -42,6 +45,7 @@ class MockAnonymousUser:
|
||||
# ReactChannel Base Class Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReactChannelBaseTests(TestCase):
|
||||
"""Tests for ReactChannel base class."""
|
||||
|
||||
@@ -115,6 +119,7 @@ class ReactChannelBaseTests(TestCase):
|
||||
# Channel with Typed Messages Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TypedMessagesTests(TestCase):
|
||||
"""Tests for channels with Pydantic message types."""
|
||||
|
||||
@@ -179,9 +184,7 @@ class TypedMessagesTests(TestCase):
|
||||
|
||||
# Test message model
|
||||
msg = BroadcastChannel.DjangoMessage(
|
||||
user="john",
|
||||
text="Hello world",
|
||||
created_at="2024-01-15T10:00:00Z"
|
||||
user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
|
||||
)
|
||||
self.assertEqual(msg.user, "john")
|
||||
self.assertEqual(msg.text, "Hello world")
|
||||
@@ -207,10 +210,7 @@ class TypedMessagesTests(TestCase):
|
||||
return f"chat_{params.room}"
|
||||
|
||||
def receive(self, params, msg):
|
||||
return self.DjangoMessage(
|
||||
user=self.user.email,
|
||||
text=msg.text
|
||||
)
|
||||
return self.DjangoMessage(user=self.user.email, text=msg.text)
|
||||
|
||||
channel = ChatChannel()
|
||||
channel.user = MockUser(email="test@example.com")
|
||||
@@ -229,6 +229,7 @@ class TypedMessagesTests(TestCase):
|
||||
# Registration Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RegistrationTests(TestCase):
|
||||
"""Tests for channel registration."""
|
||||
|
||||
@@ -336,6 +337,7 @@ class RegistrationTests(TestCase):
|
||||
# Schema Export Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SchemaExportTests(TestCase):
|
||||
"""Tests for channel schema export."""
|
||||
|
||||
@@ -482,6 +484,7 @@ class SchemaExportTests(TestCase):
|
||||
# Authorization Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AuthorizationTests(TestCase):
|
||||
"""Tests for channel authorization."""
|
||||
|
||||
@@ -543,6 +546,7 @@ class AuthorizationTests(TestCase):
|
||||
# Group Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class GroupTests(TestCase):
|
||||
"""Tests for channel group management."""
|
||||
|
||||
@@ -586,6 +590,7 @@ class GroupTests(TestCase):
|
||||
# Async Methods Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AsyncMethodsTests(TestCase):
|
||||
"""Tests for async internal methods."""
|
||||
|
||||
@@ -727,6 +732,7 @@ class AsyncMethodsTests(TestCase):
|
||||
# Server Push Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ServerPushTests(TestCase):
|
||||
"""Tests for server push functionality."""
|
||||
|
||||
@@ -752,13 +758,12 @@ class ServerPushTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "notifications"
|
||||
|
||||
with patch('channels.layers.get_channel_layer') as mock_get_layer:
|
||||
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||
mock_layer = AsyncMock()
|
||||
mock_get_layer.return_value = mock_layer
|
||||
|
||||
message = NotificationChannel.DjangoMessage(
|
||||
title="Alert",
|
||||
body="Something happened"
|
||||
title="Alert", body="Something happened"
|
||||
)
|
||||
|
||||
async def test():
|
||||
@@ -789,7 +794,7 @@ class ServerPushTests(TestCase):
|
||||
def group(self, params):
|
||||
return f"room_{params.room}"
|
||||
|
||||
with patch('channels.layers.get_channel_layer') as mock_get_layer:
|
||||
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||
mock_layer = AsyncMock()
|
||||
mock_get_layer.return_value = mock_layer
|
||||
|
||||
@@ -821,24 +826,28 @@ class ServerPushTests(TestCase):
|
||||
def group(self, params=None):
|
||||
return "test"
|
||||
|
||||
with patch('channels.layers.get_channel_layer') as mock_get_layer:
|
||||
with patch("channels.layers.get_channel_layer") as mock_get_layer:
|
||||
mock_get_layer.return_value = None
|
||||
|
||||
message = TestChannel.DjangoMessage(text="test")
|
||||
|
||||
with self.assertLogs('djarea.channels', level='WARNING') as cm:
|
||||
with self.assertLogs("mizan.channels", level="WARNING") as cm:
|
||||
|
||||
async def test():
|
||||
await TestChannel.push(message=message)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
self.assertTrue(any("No channel layer configured" in msg for msg in cm.output))
|
||||
self.assertTrue(
|
||||
any("No channel layer configured" in msg for msg in cm.output)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Management Command Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ManagementCommandTests(TestCase):
|
||||
"""Tests for the export_channels_schema management command."""
|
||||
|
||||
@@ -855,7 +864,7 @@ class ManagementCommandTests(TestCase):
|
||||
from django.core.management import call_command
|
||||
|
||||
out = StringIO()
|
||||
call_command('export_channels_schema', stdout=out)
|
||||
call_command("export_channels_schema", stdout=out)
|
||||
|
||||
output = out.getvalue()
|
||||
|
||||
@@ -863,7 +872,7 @@ class ManagementCommandTests(TestCase):
|
||||
schema = json.loads(output)
|
||||
|
||||
self.assertIn("openapi", schema)
|
||||
self.assertIn("x-djarea-channels", schema)
|
||||
self.assertIn("x-mizan-channels", schema)
|
||||
|
||||
def test_export_command_includes_registered_channels(self):
|
||||
"""export_channels_schema should include registered channels."""
|
||||
@@ -883,13 +892,13 @@ class ManagementCommandTests(TestCase):
|
||||
register(TestChannel, "export-test")
|
||||
|
||||
out = StringIO()
|
||||
call_command('export_channels_schema', stdout=out)
|
||||
call_command("export_channels_schema", stdout=out)
|
||||
|
||||
output = out.getvalue()
|
||||
schema = json.loads(output)
|
||||
|
||||
# Check that channel is in x-djarea-channels metadata
|
||||
channel_names = [c["name"] for c in schema["x-djarea-channels"]]
|
||||
# Check that channel is in x-mizan-channels metadata
|
||||
channel_names = [c["name"] for c in schema["x-mizan-channels"]]
|
||||
self.assertIn("export-test", channel_names)
|
||||
|
||||
def test_export_command_respects_indent(self):
|
||||
@@ -899,11 +908,11 @@ class ManagementCommandTests(TestCase):
|
||||
|
||||
# With indent
|
||||
out_indent = StringIO()
|
||||
call_command('export_channels_schema', indent=2, stdout=out_indent)
|
||||
call_command("export_channels_schema", indent=2, stdout=out_indent)
|
||||
|
||||
# Without indent (compact)
|
||||
out_compact = StringIO()
|
||||
call_command('export_channels_schema', indent=0, stdout=out_compact)
|
||||
call_command("export_channels_schema", indent=0, stdout=out_compact)
|
||||
|
||||
# Indented should be longer (has whitespace)
|
||||
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
|
||||
@@ -918,13 +927,14 @@ class WebSocketRPCTests(TestCase):
|
||||
"""Tests for WebSocket RPC functionality."""
|
||||
|
||||
def setUp(self):
|
||||
# Clear djarea registry
|
||||
from djarea.setup.registry import clear_registry
|
||||
# Clear mizan registry
|
||||
from mizan.setup.registry import clear_registry
|
||||
|
||||
clear_registry()
|
||||
|
||||
# Register test functions
|
||||
from djarea.client import client
|
||||
from djarea.setup.registry import register
|
||||
from mizan.client import client
|
||||
from mizan.setup.registry import register
|
||||
from pydantic import BaseModel
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
@@ -936,11 +946,13 @@ class WebSocketRPCTests(TestCase):
|
||||
@client(websocket=True)
|
||||
def rpc_echo(request, message: str) -> EchoOutput:
|
||||
return EchoOutput(echo=f"Echo: {message}")
|
||||
|
||||
register(rpc_echo, "rpc_echo")
|
||||
|
||||
@client(websocket=True)
|
||||
def rpc_add(request, a: int, b: int) -> AddOutput:
|
||||
return AddOutput(result=a + b)
|
||||
|
||||
register(rpc_add, "rpc_add")
|
||||
|
||||
@client(websocket=True)
|
||||
@@ -948,16 +960,18 @@ class WebSocketRPCTests(TestCase):
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("Authentication required")
|
||||
return EchoOutput(echo=f"Hello, {request.user.email}")
|
||||
|
||||
register(rpc_auth_required, "rpc_auth_required")
|
||||
|
||||
def tearDown(self):
|
||||
from djarea.setup.registry import clear_registry
|
||||
from mizan.setup.registry import clear_registry
|
||||
|
||||
clear_registry()
|
||||
|
||||
def test_handle_rpc_success(self):
|
||||
"""_handle_rpc should execute function and return result."""
|
||||
import asyncio
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {
|
||||
@@ -971,11 +985,13 @@ class WebSocketRPCTests(TestCase):
|
||||
consumer.send_json = mock_send_json
|
||||
|
||||
async def test():
|
||||
await consumer._handle_rpc({
|
||||
"id": "test-123",
|
||||
"fn": "rpc_echo",
|
||||
"args": {"message": "Hello"},
|
||||
})
|
||||
await consumer._handle_rpc(
|
||||
{
|
||||
"id": "test-123",
|
||||
"fn": "rpc_echo",
|
||||
"args": {"message": "Hello"},
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
@@ -989,7 +1005,7 @@ class WebSocketRPCTests(TestCase):
|
||||
def test_handle_rpc_with_multiple_args(self):
|
||||
"""_handle_rpc should handle functions with multiple arguments."""
|
||||
import asyncio
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {"user": MockUser()}
|
||||
@@ -1001,11 +1017,13 @@ class WebSocketRPCTests(TestCase):
|
||||
consumer.send_json = mock_send_json
|
||||
|
||||
async def test():
|
||||
await consumer._handle_rpc({
|
||||
"id": "add-123",
|
||||
"fn": "rpc_add",
|
||||
"args": {"a": 5, "b": 3},
|
||||
})
|
||||
await consumer._handle_rpc(
|
||||
{
|
||||
"id": "add-123",
|
||||
"fn": "rpc_add",
|
||||
"args": {"a": 5, "b": 3},
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
@@ -1016,7 +1034,7 @@ class WebSocketRPCTests(TestCase):
|
||||
def test_handle_rpc_function_not_found(self):
|
||||
"""_handle_rpc should return error for unknown function."""
|
||||
import asyncio
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {"user": MockUser()}
|
||||
@@ -1028,11 +1046,13 @@ class WebSocketRPCTests(TestCase):
|
||||
consumer.send_json = mock_send_json
|
||||
|
||||
async def test():
|
||||
await consumer._handle_rpc({
|
||||
"id": "test-456",
|
||||
"fn": "nonexistent_function",
|
||||
"args": {},
|
||||
})
|
||||
await consumer._handle_rpc(
|
||||
{
|
||||
"id": "test-456",
|
||||
"fn": "nonexistent_function",
|
||||
"args": {},
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
@@ -1044,7 +1064,7 @@ class WebSocketRPCTests(TestCase):
|
||||
def test_handle_rpc_validation_error(self):
|
||||
"""_handle_rpc should return validation error for invalid input."""
|
||||
import asyncio
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {"user": MockUser()}
|
||||
@@ -1056,11 +1076,13 @@ class WebSocketRPCTests(TestCase):
|
||||
consumer.send_json = mock_send_json
|
||||
|
||||
async def test():
|
||||
await consumer._handle_rpc({
|
||||
"id": "test-789",
|
||||
"fn": "rpc_echo",
|
||||
"args": {}, # Missing required 'message' field
|
||||
})
|
||||
await consumer._handle_rpc(
|
||||
{
|
||||
"id": "test-789",
|
||||
"fn": "rpc_echo",
|
||||
"args": {}, # Missing required 'message' field
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
@@ -1072,7 +1094,7 @@ class WebSocketRPCTests(TestCase):
|
||||
def test_handle_rpc_missing_id(self):
|
||||
"""_handle_rpc should return error when id is missing."""
|
||||
import asyncio
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {"user": MockUser()}
|
||||
@@ -1084,11 +1106,13 @@ class WebSocketRPCTests(TestCase):
|
||||
consumer.send_json = mock_send_json
|
||||
|
||||
async def test():
|
||||
await consumer._handle_rpc({
|
||||
"fn": "rpc_echo",
|
||||
"args": {"message": "test"},
|
||||
# Missing 'id'
|
||||
})
|
||||
await consumer._handle_rpc(
|
||||
{
|
||||
"fn": "rpc_echo",
|
||||
"args": {"message": "test"},
|
||||
# Missing 'id'
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
@@ -1099,7 +1123,7 @@ class WebSocketRPCTests(TestCase):
|
||||
def test_handle_rpc_missing_fn(self):
|
||||
"""_handle_rpc should return error when fn is missing."""
|
||||
import asyncio
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {"user": MockUser()}
|
||||
@@ -1111,11 +1135,13 @@ class WebSocketRPCTests(TestCase):
|
||||
consumer.send_json = mock_send_json
|
||||
|
||||
async def test():
|
||||
await consumer._handle_rpc({
|
||||
"id": "test-abc",
|
||||
"args": {"message": "test"},
|
||||
# Missing 'fn'
|
||||
})
|
||||
await consumer._handle_rpc(
|
||||
{
|
||||
"id": "test-abc",
|
||||
"args": {"message": "test"},
|
||||
# Missing 'fn'
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
@@ -1127,7 +1153,7 @@ class WebSocketRPCTests(TestCase):
|
||||
def test_handle_rpc_with_unauthenticated_user(self):
|
||||
"""_handle_rpc should handle permission errors correctly."""
|
||||
import asyncio
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {"user": MockAnonymousUser()}
|
||||
@@ -1139,11 +1165,13 @@ class WebSocketRPCTests(TestCase):
|
||||
consumer.send_json = mock_send_json
|
||||
|
||||
async def test():
|
||||
await consumer._handle_rpc({
|
||||
"id": "auth-test",
|
||||
"fn": "rpc_auth_required",
|
||||
"args": {},
|
||||
})
|
||||
await consumer._handle_rpc(
|
||||
{
|
||||
"id": "auth-test",
|
||||
"fn": "rpc_auth_required",
|
||||
"args": {},
|
||||
}
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(test())
|
||||
|
||||
@@ -1154,7 +1182,7 @@ class WebSocketRPCTests(TestCase):
|
||||
|
||||
def test_websocket_request_adapter(self):
|
||||
"""WebSocketRequest should provide correct user and session."""
|
||||
from djarea.channels.connection import WebSocketRequest
|
||||
from mizan.channels.connection import WebSocketRequest
|
||||
|
||||
mock_user = MockUser(email="ws@example.com")
|
||||
scope = {
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Djarea server functions.
|
||||
Tests for mizan server functions.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -10,10 +10,23 @@ from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from djarea.client.executor import ErrorCode, FunctionError, FunctionResult, execute_function
|
||||
from djarea.setup.registry import clear_registry, register, register_as, register_form, get_schema, get_contexts, get_function
|
||||
from djarea.client import ServerFunction, client
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.client.executor import (
|
||||
ErrorCode,
|
||||
FunctionError,
|
||||
FunctionResult,
|
||||
execute_function,
|
||||
)
|
||||
from mizan.setup.registry import (
|
||||
clear_registry,
|
||||
register,
|
||||
register_as,
|
||||
register_form,
|
||||
get_schema,
|
||||
get_contexts,
|
||||
get_function,
|
||||
)
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -50,17 +63,19 @@ def setup_function_style_tests():
|
||||
"""Register function-style test functions.
|
||||
|
||||
Note: Since @client no longer auto-registers (registration happens via
|
||||
djarea_clients() discovery), we explicitly register each function here.
|
||||
mizan_clients() discovery), we explicitly register each function here.
|
||||
"""
|
||||
|
||||
@client
|
||||
def fn_echo(request: HttpRequest, message: str) -> EchoOutput:
|
||||
return EchoOutput(echo=f"Echo: {message}")
|
||||
|
||||
register(fn_echo, "fn_echo")
|
||||
|
||||
@client
|
||||
def fn_no_input(request: HttpRequest) -> ValueOutput:
|
||||
return ValueOutput(value=42)
|
||||
|
||||
register(fn_no_input, "fn_no_input")
|
||||
|
||||
@client
|
||||
@@ -68,6 +83,7 @@ def setup_function_style_tests():
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("Authentication required")
|
||||
return UserEmailOutput(user_email=request.user.email)
|
||||
|
||||
register(fn_auth_required, "fn_auth_required")
|
||||
|
||||
@client
|
||||
@@ -78,11 +94,13 @@ def setup_function_style_tests():
|
||||
if age > 150:
|
||||
raise ValueError("Age must be realistic")
|
||||
return ValidOutput(valid=True)
|
||||
|
||||
register(fn_validation, "fn_validation")
|
||||
|
||||
@client
|
||||
def fn_error(request: HttpRequest) -> ErrorOutput:
|
||||
raise RuntimeError("Something went wrong")
|
||||
|
||||
register(fn_error, "fn_error")
|
||||
|
||||
|
||||
@@ -401,6 +419,7 @@ class RegistryTests(TestCase):
|
||||
@client
|
||||
def decorated_fn(request: HttpRequest) -> TestOutput:
|
||||
return TestOutput(result="success")
|
||||
|
||||
register(decorated_fn, "decorated_fn")
|
||||
|
||||
fn = get_function("decorated_fn")
|
||||
@@ -424,6 +443,7 @@ class RegistryTests(TestCase):
|
||||
@client
|
||||
def my_client(request: HttpRequest) -> AutoOutput:
|
||||
return AutoOutput(value=1)
|
||||
|
||||
register(my_client, "my_client")
|
||||
|
||||
# Name is the function name, not kebab-case
|
||||
@@ -484,9 +504,10 @@ class ContextTests(TestCase):
|
||||
class CtxOutput(BaseModel):
|
||||
data: str
|
||||
|
||||
@client(context='global')
|
||||
@client(context="global")
|
||||
def global_context(request: HttpRequest) -> CtxOutput:
|
||||
return CtxOutput(data="test")
|
||||
|
||||
register(global_context, "global_context")
|
||||
|
||||
fn = get_function("global_context")
|
||||
@@ -498,9 +519,10 @@ class ContextTests(TestCase):
|
||||
class CtxOutput(BaseModel):
|
||||
data: str
|
||||
|
||||
@client(context='local')
|
||||
@client(context="local")
|
||||
def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
|
||||
return CtxOutput(data=f"user_{user_id}")
|
||||
|
||||
register(local_context, "local_context")
|
||||
|
||||
fn = get_function("local_context")
|
||||
@@ -509,7 +531,8 @@ class ContextTests(TestCase):
|
||||
def test_context_invalid_value_raises(self):
|
||||
"""Test that invalid context values raise ValueError."""
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
@client(context='invalid')
|
||||
|
||||
@client(context="invalid")
|
||||
def bad_context(request: HttpRequest) -> ValidOutput:
|
||||
return ValidOutput(valid=True)
|
||||
|
||||
@@ -522,17 +545,19 @@ class ContextTests(TestCase):
|
||||
class Ctx1Output(BaseModel):
|
||||
value: int
|
||||
|
||||
@client(context='global')
|
||||
@client(context="global")
|
||||
def ctx1(request: HttpRequest) -> Ctx1Output:
|
||||
return Ctx1Output(value=1)
|
||||
|
||||
register(ctx1, "ctx1")
|
||||
|
||||
class Ctx2Output(BaseModel):
|
||||
value: int
|
||||
|
||||
@client(context='local')
|
||||
@client(context="local")
|
||||
def ctx2(request: HttpRequest, id: int) -> Ctx2Output:
|
||||
return Ctx2Output(value=id)
|
||||
|
||||
register(ctx2, "ctx2")
|
||||
|
||||
contexts = get_contexts()
|
||||
@@ -568,7 +593,8 @@ class ChannelTests(TestCase):
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
from djarea.setup.registry import get_channel
|
||||
from mizan.setup.registry import get_channel
|
||||
|
||||
channel = get_channel("test-channel")
|
||||
self.assertEqual(channel, TestChannel)
|
||||
|
||||
@@ -669,6 +695,7 @@ class TypeAnnotationTests(TestCase):
|
||||
"""Test that missing return type raises TypeError."""
|
||||
|
||||
with self.assertRaises(TypeError) as ctx:
|
||||
|
||||
@client
|
||||
def no_return(request: HttpRequest):
|
||||
pass
|
||||
@@ -681,21 +708,25 @@ class TypeAnnotationTests(TestCase):
|
||||
@client
|
||||
def return_int(request: HttpRequest, a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
register(return_int, "return_int")
|
||||
|
||||
@client
|
||||
def return_str(request: HttpRequest, name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
register(return_str, "return_str")
|
||||
|
||||
@client
|
||||
def return_dict(request: HttpRequest) -> dict:
|
||||
return {"key": "value"}
|
||||
|
||||
register(return_dict, "return_dict")
|
||||
|
||||
@client
|
||||
def return_list(request: HttpRequest) -> list:
|
||||
return [1, 2, 3]
|
||||
|
||||
register(return_list, "return_list")
|
||||
|
||||
# Verify all registered correctly
|
||||
@@ -731,6 +762,7 @@ class TypeAnnotationTests(TestCase):
|
||||
@client
|
||||
def dict_input(request: HttpRequest, data: dict) -> GoodOutput:
|
||||
return GoodOutput(value=len(data))
|
||||
|
||||
register(dict_input, "dict_input")
|
||||
|
||||
fn = get_function("dict_input")
|
||||
@@ -766,7 +798,7 @@ class TypeAnnotationTests(TestCase):
|
||||
# In Python 3.10+, X | None creates a types.UnionType
|
||||
self.assertTrue(
|
||||
isinstance(fn.Output, types.UnionType) or fn.Output is UserProfile,
|
||||
f"Expected UnionType or UserProfile, got {fn.Output}"
|
||||
f"Expected UnionType or UserProfile, got {fn.Output}",
|
||||
)
|
||||
|
||||
# Test execution returns data directly, not wrapped
|
||||
@@ -802,11 +834,13 @@ class RPCModeTests(TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
# Reset settings cache
|
||||
from djarea.setup.settings import clear_settings_cache
|
||||
from mizan.setup.settings import clear_settings_cache
|
||||
|
||||
clear_settings_cache()
|
||||
|
||||
def test_client_decorator_with_websocket_true(self):
|
||||
"""Test @client(websocket=True) stores websocket=True in metadata."""
|
||||
|
||||
@client(websocket=True)
|
||||
def websocket_enabled(request) -> EchoOutput:
|
||||
return EchoOutput(echo="ws")
|
||||
@@ -815,6 +849,7 @@ class RPCModeTests(TestCase):
|
||||
|
||||
def test_client_decorator_without_websocket(self):
|
||||
"""Test @client without websocket parameter is HTTP-only (no websocket in meta)."""
|
||||
|
||||
@client
|
||||
def http_only(request) -> EchoOutput:
|
||||
return EchoOutput(echo="http")
|
||||
@@ -824,9 +859,11 @@ class RPCModeTests(TestCase):
|
||||
|
||||
def test_websocket_enabled_function_works_via_http(self):
|
||||
"""Test that @client(websocket=True) functions still work via HTTP."""
|
||||
|
||||
@client(websocket=True)
|
||||
def ws_enabled(request) -> EchoOutput:
|
||||
return EchoOutput(echo="ws")
|
||||
|
||||
register(ws_enabled, "ws_enabled")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -840,9 +877,11 @@ class RPCModeTests(TestCase):
|
||||
|
||||
def test_http_only_function_works_via_http(self):
|
||||
"""Test that @client functions (HTTP-only by default) work via HTTP."""
|
||||
|
||||
@client
|
||||
def http_only(request) -> EchoOutput:
|
||||
return EchoOutput(echo="http")
|
||||
|
||||
register(http_only, "http_only")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -855,9 +894,11 @@ class RPCModeTests(TestCase):
|
||||
|
||||
def test_default_function_works_via_http(self):
|
||||
"""Test that @client functions without websocket param work via HTTP (default)."""
|
||||
|
||||
@client
|
||||
def default_func(request) -> EchoOutput:
|
||||
return EchoOutput(echo="default")
|
||||
|
||||
register(default_func, "default_func")
|
||||
|
||||
request = self.factory.post("/")
|
||||
@@ -870,12 +911,12 @@ class RPCModeTests(TestCase):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DjareaFormMixin Tests
|
||||
# mizanFormMixin Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DjareaFormMixinTests(TestCase):
|
||||
"""Tests for DjareaFormMixin and DjareaFormMeta."""
|
||||
class mizanFormMixinTests(TestCase):
|
||||
"""Tests for mizanFormMixin and mizanFormMeta."""
|
||||
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
@@ -890,12 +931,12 @@ class DjareaFormMixinTests(TestCase):
|
||||
return request
|
||||
|
||||
def test_form_mixin_registration(self):
|
||||
"""Test that DjareaFormMixin auto-registers server functions."""
|
||||
"""Test that mizanFormMixin auto-registers server functions."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class TestForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="test_form")
|
||||
class TestForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="test_form")
|
||||
name = forms.CharField()
|
||||
|
||||
# Verify functions were registered
|
||||
@@ -910,10 +951,10 @@ class DjareaFormMixinTests(TestCase):
|
||||
def test_form_schema_function(self):
|
||||
"""Test that schema function returns form field definitions."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact_schema_test",
|
||||
title="Contact Us",
|
||||
submit_label="Send",
|
||||
@@ -939,31 +980,35 @@ class DjareaFormMixinTests(TestCase):
|
||||
def test_form_validate_function(self):
|
||||
"""Test that validate function returns validation errors."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class ValidationForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="validation_test")
|
||||
class ValidationForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="validation_test")
|
||||
email = forms.EmailField()
|
||||
|
||||
request = self._make_request()
|
||||
|
||||
# Invalid email
|
||||
result = execute_function(request, "validation_test.validate", {"data": {"email": "not-an-email"}})
|
||||
result = execute_function(
|
||||
request, "validation_test.validate", {"data": {"email": "not-an-email"}}
|
||||
)
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertTrue(len(result.data["errors"]) > 0)
|
||||
|
||||
# Valid email
|
||||
result = execute_function(request, "validation_test.validate", {"data": {"email": "test@example.com"}})
|
||||
result = execute_function(
|
||||
request, "validation_test.validate", {"data": {"email": "test@example.com"}}
|
||||
)
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(len(result.data["errors"]), 0)
|
||||
|
||||
def test_form_submit_function_success(self):
|
||||
"""Test that submit function calls on_submit_success."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class SubmitForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="submit_test")
|
||||
class SubmitForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="submit_test")
|
||||
value = forms.CharField()
|
||||
|
||||
def on_submit_success(self, request):
|
||||
@@ -978,10 +1023,10 @@ class DjareaFormMixinTests(TestCase):
|
||||
def test_form_submit_function_validation_failure(self):
|
||||
"""Test that submit function returns errors on validation failure."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class RequiredForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="required_test")
|
||||
class RequiredForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="required_test")
|
||||
required_field = forms.CharField()
|
||||
|
||||
request = self._make_request()
|
||||
@@ -993,10 +1038,10 @@ class DjareaFormMixinTests(TestCase):
|
||||
self.assertIn("errors", result.data)
|
||||
|
||||
def test_form_meta_serialization(self):
|
||||
"""Test that DjareaFormMeta serializes correctly (auth excluded)."""
|
||||
from djarea.forms import DjareaFormMeta
|
||||
"""Test that mizanFormMeta serializes correctly (auth excluded)."""
|
||||
from mizan.forms import mizanFormMeta
|
||||
|
||||
meta = DjareaFormMeta(
|
||||
meta = mizanFormMeta(
|
||||
name="test",
|
||||
title="Test Form",
|
||||
subtitle="A test form",
|
||||
@@ -1016,17 +1061,24 @@ class DjareaFormMixinTests(TestCase):
|
||||
def test_form_with_custom_init_kwargs(self):
|
||||
"""Test that get_init_kwargs is called during form instantiation."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class FormWithUser(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="init_kwargs_test")
|
||||
class FormWithUser(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="init_kwargs_test")
|
||||
user_email = forms.CharField()
|
||||
|
||||
@classmethod
|
||||
def get_init_kwargs(cls, request):
|
||||
return {"initial": {"user_email": request.user.email if request.user.is_authenticated else ""}}
|
||||
return {
|
||||
"initial": {
|
||||
"user_email": request.user.email
|
||||
if request.user.is_authenticated
|
||||
else ""
|
||||
}
|
||||
}
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
user = MagicMock()
|
||||
user.is_authenticated = True
|
||||
user.email = "test@example.com"
|
||||
@@ -1043,10 +1095,10 @@ class DjareaFormMixinTests(TestCase):
|
||||
def test_formset_functions_not_registered_by_default(self):
|
||||
"""Test that formset functions are not registered by default."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class NoFormsetForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="no_formset_test")
|
||||
class NoFormsetForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="no_formset_test")
|
||||
field = forms.CharField()
|
||||
|
||||
# Formset functions should not exist
|
||||
@@ -1057,10 +1109,10 @@ class DjareaFormMixinTests(TestCase):
|
||||
def test_formset_functions_registered_when_enabled(self):
|
||||
"""Test that formset functions are registered when enable_formset=True."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
|
||||
class WithFormsetForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(name="with_formset_test", enable_formset=True)
|
||||
class WithFormsetForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(name="with_formset_test", enable_formset=True)
|
||||
field = forms.CharField()
|
||||
|
||||
# Formset functions should exist
|
||||
@@ -1068,13 +1120,13 @@ class DjareaFormMixinTests(TestCase):
|
||||
self.assertIsNotNone(get_function("with_formset_test.formset.validate"))
|
||||
self.assertIsNotNone(get_function("with_formset_test.formset.submit"))
|
||||
|
||||
def test_form_without_djarea_not_registered(self):
|
||||
"""Test that forms without djarea attribute are not registered."""
|
||||
def test_form_without_mizan_not_registered(self):
|
||||
"""Test that forms without mizan attribute are not registered."""
|
||||
from django import forms
|
||||
from djarea.forms import DjareaFormMixin
|
||||
from mizan.forms import mizanFormMixin
|
||||
|
||||
class PlainForm(DjareaFormMixin, forms.Form):
|
||||
# No djarea attribute
|
||||
class PlainForm(mizanFormMixin, forms.Form):
|
||||
# No mizan attribute
|
||||
field = forms.CharField()
|
||||
|
||||
# Should not be registered
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Advanced Penetration Tests for Djarea Server Functions
|
||||
Advanced Penetration Tests for mizan Server Functions
|
||||
|
||||
These tests simulate a professional security researcher attempting to break
|
||||
the protocol. Focus areas:
|
||||
@@ -36,14 +36,14 @@ from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from djarea.client.executor import (
|
||||
from mizan.client.executor import (
|
||||
ErrorCode,
|
||||
FunctionError,
|
||||
FunctionResult,
|
||||
execute_function,
|
||||
)
|
||||
from djarea.setup.registry import clear_registry, get_function, register
|
||||
from djarea.client import ServerFunction, client
|
||||
from mizan.setup.registry import clear_registry, get_function, register
|
||||
from mizan.client import ServerFunction, client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -86,16 +86,19 @@ class MemoryExhaustionTests(TestCase):
|
||||
@client
|
||||
def process_data(request: HttpRequest, data: dict) -> SimpleOutput:
|
||||
return SimpleOutput(value=str(len(str(data))))
|
||||
|
||||
register(process_data, "process_data")
|
||||
|
||||
@client
|
||||
def process_string(request: HttpRequest, text: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=f"len={len(text)}")
|
||||
|
||||
register(process_string, "process_string")
|
||||
|
||||
@client
|
||||
def process_list(request: HttpRequest, items: list) -> SimpleOutput:
|
||||
return SimpleOutput(value=str(len(items)))
|
||||
|
||||
register(process_list, "process_list")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -141,7 +144,9 @@ class MemoryExhaustionTests(TestCase):
|
||||
def create_wide_nested(depth, width):
|
||||
if depth == 0:
|
||||
return "leaf"
|
||||
return {f"key_{i}": create_wide_nested(depth - 1, width) for i in range(width)}
|
||||
return {
|
||||
f"key_{i}": create_wide_nested(depth - 1, width) for i in range(width)
|
||||
}
|
||||
|
||||
# 5 levels deep, 10 wide = 10^5 = 100,000 nodes
|
||||
wide_structure = create_wide_nested(5, 10)
|
||||
@@ -225,16 +230,19 @@ class TypeConfusionTests(TestCase):
|
||||
@client
|
||||
def numeric_func(request: HttpRequest, value: float) -> NumericOutput:
|
||||
return NumericOutput(result=value * 2)
|
||||
|
||||
register(numeric_func, "numeric_func")
|
||||
|
||||
@client
|
||||
def any_input(request: HttpRequest, data: Any) -> SimpleOutput:
|
||||
return SimpleOutput(value=str(type(data).__name__))
|
||||
|
||||
register(any_input, "any_input")
|
||||
|
||||
@client
|
||||
def bool_func(request: HttpRequest, flag: bool) -> SimpleOutput:
|
||||
return SimpleOutput(value="yes" if flag else "no")
|
||||
|
||||
register(bool_func, "bool_func")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -254,8 +262,9 @@ class TypeConfusionTests(TestCase):
|
||||
request = self._make_request()
|
||||
|
||||
import math
|
||||
|
||||
# JSON doesn't support NaN directly, but we test the boundary
|
||||
result = execute_function(request, "numeric_func", {"value": float('nan')})
|
||||
result = execute_function(request, "numeric_func", {"value": float("nan")})
|
||||
|
||||
# numeric_func doubles the value; NaN * 2 is still NaN
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
@@ -267,21 +276,21 @@ class TypeConfusionTests(TestCase):
|
||||
"""
|
||||
request = self._make_request()
|
||||
|
||||
result = execute_function(request, "numeric_func", {"value": float('inf')})
|
||||
result = execute_function(request, "numeric_func", {"value": float("inf")})
|
||||
|
||||
# inf * 2 is still inf
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(result.data["result"], float('inf'))
|
||||
self.assertEqual(result.data["result"], float("inf"))
|
||||
|
||||
def test_negative_infinity_handling(self):
|
||||
"""Test handling of negative infinity."""
|
||||
request = self._make_request()
|
||||
|
||||
result = execute_function(request, "numeric_func", {"value": float('-inf')})
|
||||
result = execute_function(request, "numeric_func", {"value": float("-inf")})
|
||||
|
||||
# -inf * 2 is still -inf
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
self.assertEqual(result.data["result"], float('-inf'))
|
||||
self.assertEqual(result.data["result"], float("-inf"))
|
||||
|
||||
def test_very_small_float(self):
|
||||
"""Test handling of very small floats (denormalized)."""
|
||||
@@ -304,7 +313,7 @@ class TypeConfusionTests(TestCase):
|
||||
result = execute_function(request, "numeric_func", {"value": huge})
|
||||
# Doubling max float should overflow to inf
|
||||
if isinstance(result, FunctionResult):
|
||||
self.assertEqual(result.data["result"], float('inf'))
|
||||
self.assertEqual(result.data["result"], float("inf"))
|
||||
|
||||
def test_boolean_type_confusion(self):
|
||||
"""
|
||||
@@ -319,7 +328,7 @@ class TypeConfusionTests(TestCase):
|
||||
(True, "yes"),
|
||||
(False, "no"),
|
||||
(1, "yes"), # int 1 -> bool True
|
||||
(0, "no"), # int 0 -> bool False
|
||||
(0, "no"), # int 0 -> bool False
|
||||
("true", "yes"), # string coercion
|
||||
("false", "no"),
|
||||
]
|
||||
@@ -401,12 +410,14 @@ class RaceConditionTests(TestCase):
|
||||
test_instance.executions.append(exec_time)
|
||||
|
||||
return TimingOutput(authenticated=is_auth, timestamp=exec_time)
|
||||
|
||||
register(timed_auth_func, "timed_auth_func")
|
||||
|
||||
@client
|
||||
def counter_func(request: HttpRequest) -> SimpleOutput:
|
||||
test_instance.call_count += 1
|
||||
return SimpleOutput(value=str(test_instance.call_count))
|
||||
|
||||
register(counter_func, "counter_func")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -454,6 +465,7 @@ class RaceConditionTests(TestCase):
|
||||
Simulates checking if the user authentication state could change
|
||||
between validation and execution.
|
||||
"""
|
||||
|
||||
# Create a user mock that changes state
|
||||
class MutableUser:
|
||||
def __init__(self):
|
||||
@@ -510,6 +522,7 @@ class PydanticBypassTests(TestCase):
|
||||
@client
|
||||
def typed_func(request: HttpRequest, count: int, name: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=f"{name}:{count}")
|
||||
|
||||
register(typed_func, "typed_func")
|
||||
|
||||
@client
|
||||
@@ -518,6 +531,7 @@ class PydanticBypassTests(TestCase):
|
||||
if "@" not in email:
|
||||
raise ValueError("Invalid email format")
|
||||
return SimpleOutput(value=email)
|
||||
|
||||
register(strict_func, "strict_func")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -537,7 +551,9 @@ class PydanticBypassTests(TestCase):
|
||||
self.assertIsInstance(result, FunctionResult)
|
||||
|
||||
# Invalid type - dict for int
|
||||
result = execute_function(request, "typed_func", {"count": {"nested": 1}, "name": "test"})
|
||||
result = execute_function(
|
||||
request, "typed_func", {"count": {"nested": 1}, "name": "test"}
|
||||
)
|
||||
self.assertIsInstance(result, FunctionError)
|
||||
self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)
|
||||
|
||||
@@ -606,13 +622,14 @@ class WebSocketProtocolTests(TestCase):
|
||||
@client
|
||||
def ws_func(request: HttpRequest, data: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=data)
|
||||
|
||||
register(ws_func, "ws_func")
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
|
||||
def _create_consumer(self, user=None):
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
consumer.scope = {"user": user or AnonymousUser()}
|
||||
@@ -680,7 +697,7 @@ class WebSocketProtocolTests(TestCase):
|
||||
"action": "rpc",
|
||||
"id": mal_id,
|
||||
"fn": "ws_func",
|
||||
"args": {"data": "test"}
|
||||
"args": {"data": "test"},
|
||||
}
|
||||
async_to_sync(consumer.receive_json)(payload)
|
||||
|
||||
@@ -693,8 +710,8 @@ class WebSocketProtocolTests(TestCase):
|
||||
|
||||
Try rapid subscribe/unsubscribe cycles and malformed params.
|
||||
"""
|
||||
from djarea.channels import register as register_channel, ReactChannel
|
||||
from djarea.channels import _registry as channels_registry
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import _registry as channels_registry
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
channels_registry.clear()
|
||||
@@ -718,14 +735,12 @@ class WebSocketProtocolTests(TestCase):
|
||||
|
||||
# Rapid subscribe/unsubscribe
|
||||
for i in range(50):
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "test-channel",
|
||||
"params": {"room": f"room_{i}"}
|
||||
})
|
||||
async_to_sync(consumer._handle_unsubscribe)({
|
||||
"channel": "test-channel",
|
||||
"params": {"room": f"room_{i}"}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "test-channel", "params": {"room": f"room_{i}"}}
|
||||
)
|
||||
async_to_sync(consumer._handle_unsubscribe)(
|
||||
{"channel": "test-channel", "params": {"room": f"room_{i}"}}
|
||||
)
|
||||
|
||||
# Should not have any lingering subscriptions
|
||||
self.assertEqual(len(consumer._subscriptions), 0)
|
||||
@@ -736,8 +751,8 @@ class WebSocketProtocolTests(TestCase):
|
||||
"""
|
||||
Test attempting to subscribe to the same channel twice.
|
||||
"""
|
||||
from djarea.channels import register as register_channel, ReactChannel
|
||||
from djarea.channels import _registry as channels_registry
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import _registry as channels_registry
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
channels_registry.clear()
|
||||
@@ -757,17 +772,15 @@ class WebSocketProtocolTests(TestCase):
|
||||
consumer, messages = self._create_consumer()
|
||||
|
||||
# First subscription
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "dup-channel",
|
||||
"params": {}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "dup-channel", "params": {}}
|
||||
)
|
||||
self.assertIn("subscribed", messages[-1])
|
||||
|
||||
# Second subscription to same channel
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "dup-channel",
|
||||
"params": {}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "dup-channel", "params": {}}
|
||||
)
|
||||
|
||||
# Should return error about already subscribed
|
||||
self.assertIn("error", messages[-1])
|
||||
@@ -797,6 +810,7 @@ class TimingSideChannelTests(TestCase):
|
||||
@client
|
||||
def existing_func(request: HttpRequest) -> SimpleOutput:
|
||||
return SimpleOutput(value="exists")
|
||||
|
||||
register(existing_func, "existing_func")
|
||||
|
||||
@client
|
||||
@@ -804,6 +818,7 @@ class TimingSideChannelTests(TestCase):
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("Auth required")
|
||||
return SimpleOutput(value="authenticated")
|
||||
|
||||
register(auth_func, "auth_func")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -910,6 +925,7 @@ class UnicodeNormalizationTests(TestCase):
|
||||
if username == "admin":
|
||||
raise PermissionError("Reserved username")
|
||||
return SimpleOutput(value=f"Hello, {username}")
|
||||
|
||||
register(username_func, "username_func")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -1011,6 +1027,7 @@ class JSONParsingEdgeCaseTests(TestCase):
|
||||
@client
|
||||
def json_func(request: HttpRequest, data: dict) -> SimpleOutput:
|
||||
return SimpleOutput(value=json.dumps(data))
|
||||
|
||||
register(json_func, "json_func")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -1063,7 +1080,7 @@ class JSONParsingEdgeCaseTests(TestCase):
|
||||
|
||||
edge_numbers = {
|
||||
"max_safe_int": 9007199254740991, # 2^53 - 1
|
||||
"beyond_safe": 9007199254740993, # 2^53 + 1 (loses precision in JS)
|
||||
"beyond_safe": 9007199254740993, # 2^53 + 1 (loses precision in JS)
|
||||
"huge_int": 10**100,
|
||||
"tiny_float": 1e-308,
|
||||
"huge_float": 1e308,
|
||||
@@ -1097,6 +1114,7 @@ class AuthorizationBoundaryTests(TestCase):
|
||||
if target_role not in allowed_roles:
|
||||
raise PermissionError(f"Cannot escalate to {target_role}")
|
||||
return SimpleOutput(value=f"Role set to {target_role}")
|
||||
|
||||
register(escalation_func, "escalation_func")
|
||||
|
||||
def tearDown(self):
|
||||
@@ -1160,8 +1178,8 @@ class RegistrationSecurityTests(TestCase):
|
||||
Note: Re-registration of the same function name IS allowed for hot reload.
|
||||
But a DIFFERENT function cannot take over an existing name.
|
||||
"""
|
||||
from djarea.client import ServerFunction
|
||||
from djarea.setup.registry import register
|
||||
from mizan.client import ServerFunction
|
||||
from mizan.setup.registry import register
|
||||
|
||||
# Register first function
|
||||
class OriginalFunc(ServerFunction):
|
||||
@@ -1196,6 +1214,7 @@ class RegistrationSecurityTests(TestCase):
|
||||
@client
|
||||
def normal_func_name(request: HttpRequest) -> SimpleOutput:
|
||||
return SimpleOutput(value="ok")
|
||||
|
||||
register(normal_func_name, "normal_func_name")
|
||||
|
||||
fn = get_function("normal_func_name")
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Security-focused E2E tests for Djarea server functions.
|
||||
Security-focused E2E tests for mizan server functions.
|
||||
|
||||
These tests probe for potential vulnerabilities without running any
|
||||
malicious code - they simply verify that defenses work correctly.
|
||||
@@ -22,16 +22,16 @@ from django.http import HttpRequest
|
||||
from django.test import RequestFactory, TestCase, Client, override_settings
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from djarea.client.executor import (
|
||||
from mizan.client.executor import (
|
||||
ErrorCode,
|
||||
FunctionError,
|
||||
FunctionResult,
|
||||
execute_function,
|
||||
function_call_view,
|
||||
)
|
||||
from djarea.setup.registry import clear_registry, register, register_as, get_function
|
||||
from djarea.client import ServerFunction, client
|
||||
from djarea.channels import ReactChannel
|
||||
from mizan.setup.registry import clear_registry, register, register_as, get_function
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
@@ -90,6 +90,7 @@ class InputValidationSecurityTests(TestCase):
|
||||
@client
|
||||
def echo_any(request: HttpRequest, message: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=message)
|
||||
|
||||
register(echo_any, "echo_any")
|
||||
|
||||
@client
|
||||
@@ -97,16 +98,18 @@ class InputValidationSecurityTests(TestCase):
|
||||
def count_depth(obj, depth=0):
|
||||
if isinstance(obj, dict):
|
||||
return max(
|
||||
(count_depth(v, depth + 1) for v in obj.values()),
|
||||
default=depth
|
||||
(count_depth(v, depth + 1) for v in obj.values()), default=depth
|
||||
)
|
||||
return depth
|
||||
|
||||
return DeeplyNestedOutput(depth=count_depth(data))
|
||||
|
||||
register(process_nested, "process_nested")
|
||||
|
||||
@client
|
||||
def typed_input(request: HttpRequest, age: int, name: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=f"{name}:{age}")
|
||||
|
||||
register(typed_input, "typed_input")
|
||||
|
||||
def _make_request(self, user=None):
|
||||
@@ -184,8 +187,7 @@ class InputValidationSecurityTests(TestCase):
|
||||
|
||||
# Try to bypass integer validation with string
|
||||
result = execute_function(
|
||||
request, "typed_input",
|
||||
{"age": "25; DROP TABLE users", "name": "test"}
|
||||
request, "typed_input", {"age": "25; DROP TABLE users", "name": "test"}
|
||||
)
|
||||
# Pydantic should coerce "25; DROP TABLE users" and fail
|
||||
# because it's not a valid integer
|
||||
@@ -208,8 +210,9 @@ class InputValidationSecurityTests(TestCase):
|
||||
request = self._make_request()
|
||||
|
||||
result = execute_function(
|
||||
request, "echo_any",
|
||||
{"message": "test", "__proto__": "polluted", "extra": "ignored"}
|
||||
request,
|
||||
"echo_any",
|
||||
{"message": "test", "__proto__": "polluted", "extra": "ignored"},
|
||||
)
|
||||
|
||||
# Should succeed, extra fields ignored
|
||||
@@ -246,6 +249,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("Authentication required")
|
||||
return SensitiveOutput(secret="sensitive", user_id=request.user.id)
|
||||
|
||||
register(requires_auth, "requires_auth")
|
||||
|
||||
@client
|
||||
@@ -255,6 +259,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
if not request.user.is_staff:
|
||||
raise PermissionError("Admin access required")
|
||||
return AdminOnlyOutput(admin_data="secret admin data")
|
||||
|
||||
register(requires_admin, "requires_admin")
|
||||
|
||||
@client
|
||||
@@ -264,6 +269,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("User not logged in")
|
||||
return SimpleOutput(value="ok")
|
||||
|
||||
register(leaky_auth_check, "leaky_auth_check")
|
||||
|
||||
def _make_request(self, user=None):
|
||||
@@ -316,6 +322,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
def test_spoofed_is_authenticated_attribute(self):
|
||||
"""Test that spoofing is_authenticated doesn't work."""
|
||||
|
||||
# Create object that claims to be authenticated but isn't a real user
|
||||
class FakeUser:
|
||||
is_authenticated = True
|
||||
@@ -330,6 +337,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
|
||||
def test_user_id_manipulation_blocked(self):
|
||||
"""Test that user can't access other users' data via input."""
|
||||
|
||||
@client
|
||||
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
|
||||
# Properly checking: can only access own data
|
||||
@@ -338,6 +346,7 @@ class AuthorizationSecurityTests(TestCase):
|
||||
if request.user.id != target_user_id:
|
||||
raise PermissionError("Cannot access other users' data")
|
||||
return SensitiveOutput(secret="data", user_id=target_user_id)
|
||||
|
||||
register(get_user_data, "get_user_data")
|
||||
|
||||
user = MagicMock()
|
||||
@@ -380,11 +389,12 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
@client
|
||||
def public_echo(request: HttpRequest, message: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=message)
|
||||
|
||||
register(public_echo, "public_echo")
|
||||
|
||||
def test_get_method_rejected(self):
|
||||
"""Test that GET requests are rejected."""
|
||||
request = self.factory.get("/api/djarea/call/")
|
||||
request = self.factory.get("/api/mizan/call/")
|
||||
request.user = AnonymousUser()
|
||||
|
||||
response = function_call_view(request)
|
||||
@@ -396,7 +406,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
|
||||
def test_put_method_rejected(self):
|
||||
"""Test that PUT requests are rejected."""
|
||||
request = self.factory.put("/api/djarea/call/")
|
||||
request = self.factory.put("/api/mizan/call/")
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
|
||||
|
||||
@@ -406,7 +416,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
|
||||
def test_delete_method_rejected(self):
|
||||
"""Test that DELETE requests are rejected."""
|
||||
request = self.factory.delete("/api/djarea/call/")
|
||||
request = self.factory.delete("/api/mizan/call/")
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check
|
||||
|
||||
@@ -417,9 +427,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
def test_invalid_json_rejected(self):
|
||||
"""Test that invalid JSON is rejected gracefully."""
|
||||
request = self.factory.post(
|
||||
"/api/djarea/call/",
|
||||
data="{invalid json",
|
||||
content_type="application/json"
|
||||
"/api/mizan/call/", data="{invalid json", content_type="application/json"
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
# Bypass CSRF for this test
|
||||
@@ -435,9 +443,7 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
def test_empty_body_rejected(self):
|
||||
"""Test that empty body is rejected (fn field required)."""
|
||||
request = self.factory.post(
|
||||
"/api/djarea/call/",
|
||||
data="",
|
||||
content_type="application/json"
|
||||
"/api/mizan/call/", data="", content_type="application/json"
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
@@ -450,9 +456,9 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
def test_missing_fn_field_rejected(self):
|
||||
"""Test that request without fn field is rejected."""
|
||||
request = self.factory.post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
data='{"args": {"message": "test"}}',
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
@@ -467,9 +473,9 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
def test_content_type_not_enforced(self):
|
||||
"""Test behavior with wrong content type."""
|
||||
request = self.factory.post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
data='{"fn": "public_echo", "args": {"message": "test"}}',
|
||||
content_type="text/plain"
|
||||
content_type="text/plain",
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
@@ -491,9 +497,9 @@ class HTTPEndpointSecurityTests(TestCase):
|
||||
|
||||
for name in malicious_names:
|
||||
request = self.factory.post(
|
||||
"/api/djarea/call/",
|
||||
"/api/mizan/call/",
|
||||
data=json.dumps({"fn": name, "args": {}}),
|
||||
content_type="application/json"
|
||||
content_type="application/json",
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
request._dont_enforce_csrf_checks = True
|
||||
@@ -528,6 +534,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
@client(websocket=True)
|
||||
def ws_echo(request: HttpRequest, message: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=message)
|
||||
|
||||
register(ws_echo, "ws_echo")
|
||||
|
||||
@client(websocket=True)
|
||||
@@ -535,11 +542,12 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
if not request.user.is_authenticated:
|
||||
raise PermissionError("Auth required")
|
||||
return SensitiveOutput(secret="data", user_id=request.user.id)
|
||||
|
||||
register(ws_auth_required, "ws_auth_required")
|
||||
|
||||
def test_rpc_without_id_field(self):
|
||||
"""Test RPC call without required id field."""
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
@@ -552,7 +560,9 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call without id
|
||||
async_to_sync(consumer._handle_rpc)({"fn": "ws_echo", "args": {"message": "test"}})
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{"fn": "ws_echo", "args": {"message": "test"}}
|
||||
)
|
||||
|
||||
# Should return error about missing id
|
||||
self.assertEqual(len(sent_messages), 1)
|
||||
@@ -560,7 +570,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
|
||||
def test_rpc_without_fn_field(self):
|
||||
"""Test RPC call without function name."""
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
@@ -581,7 +591,7 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
|
||||
def test_rpc_nonexistent_function(self):
|
||||
"""Test RPC call to non-existent function."""
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
@@ -592,18 +602,16 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
async_to_sync(consumer._handle_rpc)({
|
||||
"id": "123",
|
||||
"fn": "nonexistent_function",
|
||||
"args": {}
|
||||
})
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{"id": "123", "fn": "nonexistent_function", "args": {}}
|
||||
)
|
||||
|
||||
self.assertEqual(sent_messages[0]["ok"], False)
|
||||
self.assertEqual(sent_messages[0]["error"]["code"], "NOT_FOUND")
|
||||
|
||||
def test_rpc_validation_error_returned(self):
|
||||
"""Test that validation errors are returned properly over RPC."""
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
@@ -615,20 +623,20 @@ class WebSocketRPCSecurityTests(TestCase):
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Call with wrong input type
|
||||
async_to_sync(consumer._handle_rpc)({
|
||||
"id": "123",
|
||||
"fn": "ws_echo",
|
||||
"args": {"message": 12345} # Should be string
|
||||
})
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{
|
||||
"id": "123",
|
||||
"fn": "ws_echo",
|
||||
"args": {"message": 12345}, # Should be string
|
||||
}
|
||||
)
|
||||
|
||||
# Pydantic coerces int to string, so this actually succeeds
|
||||
# Let's test with missing required field instead
|
||||
sent_messages.clear()
|
||||
async_to_sync(consumer._handle_rpc)({
|
||||
"id": "124",
|
||||
"fn": "ws_echo",
|
||||
"args": {} # Missing message
|
||||
})
|
||||
async_to_sync(consumer._handle_rpc)(
|
||||
{"id": "124", "fn": "ws_echo", "args": {}} # Missing message
|
||||
)
|
||||
|
||||
self.assertEqual(sent_messages[0]["ok"], False)
|
||||
self.assertEqual(sent_messages[0]["error"]["code"], "VALIDATION_ERROR")
|
||||
@@ -662,11 +670,13 @@ class InformationDisclosureTests(TestCase):
|
||||
# Simulate accessing sensitive config that might leak in error
|
||||
secret_key = "super_secret_key_12345"
|
||||
raise RuntimeError(f"Database error with key: {secret_key}")
|
||||
|
||||
register(error_with_sensitive_data, "error_with_sensitive_data")
|
||||
|
||||
@client
|
||||
def working_function(request: HttpRequest) -> SimpleOutput:
|
||||
return SimpleOutput(value="works")
|
||||
|
||||
register(working_function, "working_function")
|
||||
|
||||
def _make_request(self, user=None):
|
||||
@@ -712,9 +722,11 @@ class InformationDisclosureTests(TestCase):
|
||||
|
||||
def test_validation_errors_dont_leak_internals(self):
|
||||
"""Test that validation errors only show field-level info."""
|
||||
|
||||
@client
|
||||
def validated_func(request: HttpRequest, secret_field: str) -> SimpleOutput:
|
||||
return SimpleOutput(value=secret_field)
|
||||
|
||||
register(validated_func, "validated_func")
|
||||
|
||||
request = self._make_request()
|
||||
@@ -758,11 +770,13 @@ class InjectionPreventionTests(TestCase):
|
||||
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
|
||||
# This function just echoes - the test is about validation
|
||||
return SimpleOutput(value=user_input)
|
||||
|
||||
register(echo_safe, "echo_safe")
|
||||
|
||||
@client
|
||||
def process_dict(request: HttpRequest, data: dict) -> SimpleOutput:
|
||||
return SimpleOutput(value=str(len(data)))
|
||||
|
||||
register(process_dict, "process_dict")
|
||||
|
||||
def _make_request(self, user=None):
|
||||
@@ -847,8 +861,7 @@ class InjectionPreventionTests(TestCase):
|
||||
request = self._make_request()
|
||||
|
||||
result = execute_function(
|
||||
request, "process_dict",
|
||||
{"data": {"__proto__": {"admin": True}}}
|
||||
request, "process_dict", {"data": {"__proto__": {"admin": True}}}
|
||||
)
|
||||
|
||||
# Should succeed - it's just a dict with a key named "__proto__"
|
||||
@@ -870,18 +883,20 @@ class ChannelAuthorizationTests(TestCase):
|
||||
def setUp(self):
|
||||
clear_registry()
|
||||
# Also clear the channels registry
|
||||
from djarea.channels import _registry as channels_registry
|
||||
from mizan.channels import _registry as channels_registry
|
||||
|
||||
channels_registry.clear()
|
||||
self._register_test_channels()
|
||||
|
||||
def tearDown(self):
|
||||
clear_registry()
|
||||
from djarea.channels import _registry as channels_registry
|
||||
from mizan.channels import _registry as channels_registry
|
||||
|
||||
channels_registry.clear()
|
||||
|
||||
def _register_test_channels(self):
|
||||
"""Register test channels using the channels module's register."""
|
||||
from djarea.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
|
||||
class PublicChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
@@ -923,8 +938,8 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
def test_authorize_exception_handling(self):
|
||||
"""Test that exceptions in authorize() are handled safely."""
|
||||
from djarea.channels import register as register_channel, ReactChannel
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels import register as register_channel, ReactChannel
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
class ErrorChannel(ReactChannel):
|
||||
@@ -947,10 +962,9 @@ class ChannelAuthorizationTests(TestCase):
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "error-channel",
|
||||
"params": {}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "error-channel", "params": {}}
|
||||
)
|
||||
|
||||
# Should return error, not crash
|
||||
self.assertEqual(len(sent_messages), 1)
|
||||
@@ -958,7 +972,7 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
def test_authorize_false_blocks_subscription(self):
|
||||
"""Test that returning False from authorize blocks subscription."""
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
@@ -969,10 +983,9 @@ class ChannelAuthorizationTests(TestCase):
|
||||
sent_messages = []
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "auth-channel",
|
||||
"params": {}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "auth-channel", "params": {}}
|
||||
)
|
||||
|
||||
# Should be rejected
|
||||
self.assertIn("error", sent_messages[0])
|
||||
@@ -980,7 +993,7 @@ class ChannelAuthorizationTests(TestCase):
|
||||
|
||||
def test_param_validation_before_authorize(self):
|
||||
"""Test that params are validated before authorize is called."""
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
@@ -992,17 +1005,16 @@ class ChannelAuthorizationTests(TestCase):
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Invalid params (string instead of int)
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "room-channel",
|
||||
"params": {"room_id": "not_an_int"}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "room-channel", "params": {"room_id": "not_an_int"}}
|
||||
)
|
||||
|
||||
# Should fail validation
|
||||
self.assertIn("error", sent_messages[0])
|
||||
|
||||
def test_room_authorization_enforced(self):
|
||||
"""Test that room-level authorization is enforced."""
|
||||
from djarea.channels.connection import DjangoReactConsumer
|
||||
from mizan.channels.connection import DjangoReactConsumer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
consumer = DjangoReactConsumer()
|
||||
@@ -1015,17 +1027,15 @@ class ChannelAuthorizationTests(TestCase):
|
||||
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
|
||||
|
||||
# Room 1 - allowed
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "room-channel",
|
||||
"params": {"room_id": 1}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "room-channel", "params": {"room_id": 1}}
|
||||
)
|
||||
self.assertIn("subscribed", sent_messages[-1])
|
||||
|
||||
# Room 999 - not allowed
|
||||
async_to_sync(consumer._handle_subscribe)({
|
||||
"channel": "room-channel",
|
||||
"params": {"room_id": 999}
|
||||
})
|
||||
async_to_sync(consumer._handle_subscribe)(
|
||||
{"channel": "room-channel", "params": {"room_id": 999}}
|
||||
)
|
||||
self.assertIn("error", sent_messages[-1])
|
||||
|
||||
|
||||
@@ -1057,6 +1067,7 @@ class AbusePreventionTests(TestCase):
|
||||
@client
|
||||
def simple_func(request: HttpRequest) -> SimpleOutput:
|
||||
return SimpleOutput(value="ok")
|
||||
|
||||
register(simple_func, "simple_func")
|
||||
|
||||
def _make_request(self, user=None):
|
||||
@@ -1081,9 +1092,11 @@ class AbusePreventionTests(TestCase):
|
||||
|
||||
def test_large_batch_execution(self):
|
||||
"""Test handling of large batch of different inputs."""
|
||||
|
||||
@client
|
||||
def batch_func(request: HttpRequest, idx: int) -> SimpleOutput:
|
||||
return SimpleOutput(value=f"item_{idx}")
|
||||
|
||||
register(batch_func, "batch_func")
|
||||
|
||||
request = self._make_request()
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Stress tests for djarea.shapes — edge cases and deep nesting.
|
||||
Stress tests for mizan.shapes — edge cases and deep nesting.
|
||||
|
||||
Models: Publisher → Author → Book → Chapter → Section (5 levels deep),
|
||||
two FKs to same model, slug PK, UUID PK, self-referential FK, M2M,
|
||||
@@ -11,12 +11,18 @@ from typing import get_type_hints
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from djarea.shapes import Shape, Diff, NestedDiff
|
||||
from mizan.shapes import Shape, Diff, NestedDiff
|
||||
|
||||
import uuid
|
||||
|
||||
from tests.models import (
|
||||
Publisher, Author, Book, Chapter, Section, Tag, Category,
|
||||
Publisher,
|
||||
Author,
|
||||
Book,
|
||||
Chapter,
|
||||
Section,
|
||||
Tag,
|
||||
Category,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,6 +105,7 @@ class PublisherDetailShape(Shape[Publisher]):
|
||||
|
||||
class BookWithEditorShape(Shape[Book]):
|
||||
"""Two FKs to the same model (author + editor)."""
|
||||
|
||||
id: int | None = None
|
||||
title: str
|
||||
author: FlatAuthorShape
|
||||
@@ -117,7 +124,6 @@ class CategoryShape(Shape[Category]):
|
||||
|
||||
|
||||
class TestShapeClassCreation(TestCase):
|
||||
|
||||
def test_flat_shape_has_no_nested(self):
|
||||
self.assertEqual(FlatAuthorShape._nested, {})
|
||||
self.assertEqual(FlatAuthorShape._field_names, ["id", "name"])
|
||||
@@ -171,7 +177,9 @@ class TestShapeClassCreation(TestCase):
|
||||
self.assertIs(CategoryShape._nested["children"], CategoryShape)
|
||||
|
||||
def test_multiple_shapes_same_model_independent(self):
|
||||
self.assertLess(len(FlatBookShape._field_names), len(BookDetailShape._field_names))
|
||||
self.assertLess(
|
||||
len(FlatBookShape._field_names), len(BookDetailShape._field_names)
|
||||
)
|
||||
self.assertNotEqual(FlatBookShape._spec, BookDetailShape._spec)
|
||||
|
||||
|
||||
@@ -181,7 +189,6 @@ class TestShapeClassCreation(TestCase):
|
||||
|
||||
|
||||
class TestShapeQuery(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.publisher = Publisher.objects.create(name="Orbit", country="UK")
|
||||
@@ -189,8 +196,10 @@ class TestShapeQuery(TestCase):
|
||||
name="Ursula", bio="Legend", publisher=cls.publisher
|
||||
)
|
||||
cls.author = Author.objects.create(
|
||||
name="Ann Leckie", bio="Imperial Radch",
|
||||
publisher=cls.publisher, mentor=cls.mentor,
|
||||
name="Ann Leckie",
|
||||
bio="Imperial Radch",
|
||||
publisher=cls.publisher,
|
||||
mentor=cls.mentor,
|
||||
)
|
||||
cls.editor = Author.objects.create(
|
||||
name="Devi Pillai", bio="Editor", publisher=cls.publisher
|
||||
@@ -199,9 +208,12 @@ class TestShapeQuery(TestCase):
|
||||
cls.tag_space = Tag.objects.create(slug="space-opera", label="Space Opera")
|
||||
|
||||
cls.book = Book.objects.create(
|
||||
title="Ancillary Justice", isbn="9780316246620",
|
||||
page_count=386, is_published=True,
|
||||
author=cls.author, editor=cls.editor,
|
||||
title="Ancillary Justice",
|
||||
isbn="9780316246620",
|
||||
page_count=386,
|
||||
is_published=True,
|
||||
author=cls.author,
|
||||
editor=cls.editor,
|
||||
)
|
||||
cls.book.tags.add(cls.tag_sf, cls.tag_space)
|
||||
|
||||
@@ -211,8 +223,12 @@ class TestShapeQuery(TestCase):
|
||||
cls.ch2 = Chapter.objects.create(
|
||||
book=cls.book, number=2, title="The Ship", word_count=4800
|
||||
)
|
||||
Section.objects.create(chapter=cls.ch1, heading="Opening", body="...", position=0)
|
||||
Section.objects.create(chapter=cls.ch1, heading="Discovery", body="...", position=1)
|
||||
Section.objects.create(
|
||||
chapter=cls.ch1, heading="Opening", body="...", position=0
|
||||
)
|
||||
Section.objects.create(
|
||||
chapter=cls.ch1, heading="Discovery", body="...", position=1
|
||||
)
|
||||
|
||||
cls.root_cat = Category.objects.create(name="Fiction")
|
||||
cls.child_cat = Category.objects.create(name="Sci-Fi", parent=cls.root_cat)
|
||||
@@ -279,9 +295,12 @@ class TestShapeQuery(TestCase):
|
||||
|
||||
def test_nullable_fk_returns_none(self):
|
||||
book_no_editor = Book.objects.create(
|
||||
title="Provenance", isbn="9780316246699",
|
||||
page_count=448, is_published=True,
|
||||
author=self.author, editor=None,
|
||||
title="Provenance",
|
||||
isbn="9780316246699",
|
||||
page_count=448,
|
||||
is_published=True,
|
||||
author=self.author,
|
||||
editor=None,
|
||||
)
|
||||
results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk))
|
||||
self.assertEqual(len(results), 1)
|
||||
@@ -330,7 +349,6 @@ class TestShapeQuery(TestCase):
|
||||
|
||||
|
||||
class TestDiff(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.publisher = Publisher.objects.create(name="Tor", country="US")
|
||||
@@ -338,8 +356,11 @@ class TestDiff(TestCase):
|
||||
name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher
|
||||
)
|
||||
cls.book = Book.objects.create(
|
||||
title="Mistborn", isbn="9780765311788",
|
||||
page_count=541, is_published=True, author=cls.author,
|
||||
title="Mistborn",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=cls.author,
|
||||
)
|
||||
cls.ch1 = Chapter.objects.create(
|
||||
book=cls.book, number=1, title="Ash", word_count=6000
|
||||
@@ -352,8 +373,11 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_diff_no_changes(self):
|
||||
shape = BookCardShape(
|
||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
||||
page_count=541, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
)
|
||||
d = shape.diff()
|
||||
@@ -362,8 +386,11 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_diff_detects_field_change(self):
|
||||
shape = BookCardShape(
|
||||
id=self.book.pk, title="Mistborn: The Final Empire",
|
||||
isbn="9780765311788", page_count=541, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn: The Final Empire",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
)
|
||||
d = shape.diff()
|
||||
@@ -372,8 +399,11 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_diff_multiple_field_changes(self):
|
||||
shape = BookCardShape(
|
||||
id=self.book.pk, title="Mistborn: TFE",
|
||||
isbn="9780765311788", page_count=600, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn: TFE",
|
||||
isbn="9780765311788",
|
||||
page_count=600,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
)
|
||||
d = shape.diff()
|
||||
@@ -396,12 +426,23 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_nested_diff_detects_updated_chapter(self):
|
||||
shape = BookDetailShape(
|
||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
||||
page_count=541, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
chapters=[
|
||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash Falls", word_count=6000, sections=[]),
|
||||
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]),
|
||||
ChapterShape(
|
||||
id=self.ch1.pk,
|
||||
number=1,
|
||||
title="Ash Falls",
|
||||
word_count=6000,
|
||||
sections=[],
|
||||
),
|
||||
ChapterShape(
|
||||
id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]
|
||||
),
|
||||
],
|
||||
tags=[],
|
||||
)
|
||||
@@ -411,13 +452,22 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_nested_diff_detects_created(self):
|
||||
shape = BookDetailShape(
|
||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
||||
page_count=541, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
chapters=[
|
||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]),
|
||||
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]),
|
||||
ChapterShape(id=None, number=3, title="New Chapter", word_count=0, sections=[]),
|
||||
ChapterShape(
|
||||
id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]
|
||||
),
|
||||
ChapterShape(
|
||||
id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]
|
||||
),
|
||||
ChapterShape(
|
||||
id=None, number=3, title="New Chapter", word_count=0, sections=[]
|
||||
),
|
||||
],
|
||||
tags=[],
|
||||
)
|
||||
@@ -426,11 +476,16 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_nested_diff_detects_deleted(self):
|
||||
shape = BookDetailShape(
|
||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
||||
page_count=541, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
chapters=[
|
||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]),
|
||||
ChapterShape(
|
||||
id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]
|
||||
),
|
||||
],
|
||||
tags=[],
|
||||
)
|
||||
@@ -439,12 +494,23 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_nested_diff_combined_operations(self):
|
||||
shape = BookDetailShape(
|
||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
||||
page_count=541, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
chapters=[
|
||||
ChapterShape(id=self.ch1.pk, number=1, title="Ash Rewritten", word_count=7000, sections=[]),
|
||||
ChapterShape(id=None, number=3, title="Epilogue", word_count=2000, sections=[]),
|
||||
ChapterShape(
|
||||
id=self.ch1.pk,
|
||||
number=1,
|
||||
title="Ash Rewritten",
|
||||
word_count=7000,
|
||||
sections=[],
|
||||
),
|
||||
ChapterShape(
|
||||
id=None, number=3, title="Epilogue", word_count=2000, sections=[]
|
||||
),
|
||||
],
|
||||
tags=[],
|
||||
)
|
||||
@@ -469,10 +535,14 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_diff_strict_shows_valid_names(self):
|
||||
shape = BookDetailShape(
|
||||
id=self.book.pk, title="Mistborn", isbn="9780765311788",
|
||||
page_count=541, is_published=True,
|
||||
id=self.book.pk,
|
||||
title="Mistborn",
|
||||
isbn="9780765311788",
|
||||
page_count=541,
|
||||
is_published=True,
|
||||
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
|
||||
chapters=[], tags=[],
|
||||
chapters=[],
|
||||
tags=[],
|
||||
)
|
||||
d = shape.diff()
|
||||
with self.assertRaises(AttributeError) as ctx:
|
||||
@@ -506,8 +576,11 @@ class TestDiff(TestCase):
|
||||
|
||||
def test_diff_many_batched_query(self):
|
||||
book2 = Book.objects.create(
|
||||
title="Warbreaker", isbn="9780765320308",
|
||||
page_count=592, is_published=True, author=self.author,
|
||||
title="Warbreaker",
|
||||
isbn="9780765320308",
|
||||
page_count=592,
|
||||
is_published=True,
|
||||
author=self.author,
|
||||
)
|
||||
items = [
|
||||
FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True),
|
||||
@@ -526,7 +599,6 @@ class TestDiff(TestCase):
|
||||
|
||||
|
||||
class TestEdgeCases(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.publisher = Publisher.objects.create(name="Edge Cases Ltd", country="XX")
|
||||
@@ -545,16 +617,22 @@ class TestEdgeCases(TestCase):
|
||||
|
||||
def test_boolean_false_is_not_missing(self):
|
||||
book = Book.objects.create(
|
||||
title="Unpublished", isbn="0000000000000",
|
||||
page_count=0, is_published=False, author=self.author,
|
||||
title="Unpublished",
|
||||
isbn="0000000000000",
|
||||
page_count=0,
|
||||
is_published=False,
|
||||
author=self.author,
|
||||
)
|
||||
results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk))
|
||||
self.assertIs(results[0].is_published, False)
|
||||
|
||||
def test_zero_integer_is_not_missing(self):
|
||||
book = Book.objects.create(
|
||||
title="Empty", isbn="0000000000001",
|
||||
page_count=0, is_published=False, author=self.author,
|
||||
title="Empty",
|
||||
isbn="0000000000001",
|
||||
page_count=0,
|
||||
is_published=False,
|
||||
author=self.author,
|
||||
)
|
||||
results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk))
|
||||
self.assertEqual(results[0].page_count, 0)
|
||||
@@ -562,8 +640,10 @@ class TestEdgeCases(TestCase):
|
||||
def test_large_queryset(self):
|
||||
books = [
|
||||
Book(
|
||||
title=f"Book {i}", isbn=f"{i:013d}",
|
||||
page_count=i * 10, is_published=i % 2 == 0,
|
||||
title=f"Book {i}",
|
||||
isbn=f"{i:013d}",
|
||||
page_count=i * 10,
|
||||
is_published=i % 2 == 0,
|
||||
author=self.author,
|
||||
)
|
||||
for i in range(100)
|
||||
@@ -574,8 +654,11 @@ class TestEdgeCases(TestCase):
|
||||
|
||||
def test_diff_on_boolean_change(self):
|
||||
book = Book.objects.create(
|
||||
title="Toggle", isbn="1111111111111",
|
||||
page_count=100, is_published=False, author=self.author,
|
||||
title="Toggle",
|
||||
isbn="1111111111111",
|
||||
page_count=100,
|
||||
is_published=False,
|
||||
author=self.author,
|
||||
)
|
||||
shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True)
|
||||
d = shape.diff()
|
||||
@@ -584,8 +667,11 @@ class TestEdgeCases(TestCase):
|
||||
|
||||
def test_diff_unchanged_returns_empty(self):
|
||||
book = Book.objects.create(
|
||||
title="Same", isbn="2222222222222",
|
||||
page_count=200, is_published=True, author=self.author,
|
||||
title="Same",
|
||||
isbn="2222222222222",
|
||||
page_count=200,
|
||||
is_published=True,
|
||||
author=self.author,
|
||||
)
|
||||
shape = FlatBookShape(id=book.pk, title="Same", is_published=True)
|
||||
d = shape.diff()
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
Djarea URL Configuration
|
||||
mizan URL Configuration
|
||||
|
||||
Single integration point for all djarea HTTP endpoints:
|
||||
Single integration point for all mizan HTTP endpoints:
|
||||
- GET /session/ - Initialize session and get CSRF token (for SSR)
|
||||
- POST /call/ - Server function calls (HTTP transport)
|
||||
|
||||
Security:
|
||||
- Schema export is NOT exposed over HTTP to prevent API enumeration
|
||||
- Use the management command instead: python manage.py export_djarea_schema
|
||||
- Use the management command instead: python manage.py export_mizan_schema
|
||||
"""
|
||||
|
||||
from django.http import JsonResponse
|
||||
@@ -17,7 +17,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
|
||||
from .client.executor import function_call_view
|
||||
|
||||
app_name = "djarea"
|
||||
app_name = "mizan"
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -1,4 +1,8 @@
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
BaseUserManager,
|
||||
PermissionsMixin,
|
||||
)
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -23,7 +27,7 @@ class EmailUserManager(BaseUserManager):
|
||||
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||
"""Minimal user model with email as USERNAME_FIELD.
|
||||
|
||||
Matches the calling convention used in djarea's test suite:
|
||||
Matches the calling convention used in mizan's test suite:
|
||||
User.objects.create_user(email="...", password="...", is_staff=True)
|
||||
"""
|
||||
|
||||
@@ -90,7 +94,11 @@ class Book(TimestampMixin):
|
||||
is_published = models.BooleanField(default=False)
|
||||
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
|
||||
editor = models.ForeignKey(
|
||||
Author, on_delete=models.SET_NULL, null=True, blank=True, related_name="edited_books",
|
||||
Author,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="edited_books",
|
||||
)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name="books")
|
||||
|
||||
@@ -112,7 +120,9 @@ class Chapter(TimestampMixin):
|
||||
|
||||
class Section(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="sections")
|
||||
chapter = models.ForeignKey(
|
||||
Chapter, on_delete=models.CASCADE, related_name="sections"
|
||||
)
|
||||
heading = models.CharField(max_length=300)
|
||||
body = models.TextField(default="")
|
||||
position = models.IntegerField(default=0)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Django settings for running djarea's test suite standalone.
|
||||
Django settings for running mizan's test suite standalone.
|
||||
|
||||
Usage:
|
||||
cd django/
|
||||
@@ -22,7 +22,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"djarea",
|
||||
"mizan",
|
||||
"tests",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Djarea E2E Integration Tests
|
||||
* mizan E2E Integration Tests
|
||||
*
|
||||
* Real Chromium → Real React app (generated hooks) → Real Django backend
|
||||
*
|
||||
* Every test uses the generated Djarea API, not raw call() or fetch().
|
||||
* Every test uses the generated mizan API, not raw call() or fetch().
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
@@ -150,7 +150,7 @@ test.describe('generated form hooks', () => {
|
||||
expect(result.fields.password).toBeDefined()
|
||||
})
|
||||
|
||||
test('useContactForm loads schema with DjareaFormMeta', async ({ page }) => {
|
||||
test('useContactForm loads schema with mizanFormMeta', async ({ page }) => {
|
||||
await fixture(page, 'form-contact-schema')
|
||||
const result = await getResult(page)
|
||||
expect(result.title).toBe('Contact Us')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8" /><title>Djarea E2E Harness</title></head>
|
||||
<head><meta charset="UTF-8" /><title>mizan E2E Harness</title></head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "djarea-e2e-harness",
|
||||
"name": "mizan-e2e-harness",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite --port 5174"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rythazhur/djarea": "file:../../react",
|
||||
"@rythazhur/mizan": "file:../../react",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Djarea API - Consolidated Exports
|
||||
* mizan API - Consolidated Exports
|
||||
*
|
||||
* Import everything from here:
|
||||
*
|
||||
@@ -15,11 +15,11 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
// AUTO-GENERATED by djarea - do not edit manually
|
||||
// AUTO-GENERATED by mizan - do not edit manually
|
||||
// Regenerate with: npm run schemas
|
||||
|
||||
// =============================================================================
|
||||
// Djarea Provider & Hooks
|
||||
// mizan Provider & Hooks
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
@@ -55,9 +55,9 @@ export {
|
||||
useJwtObtain,
|
||||
useJwtRefresh,
|
||||
|
||||
// Re-exports from djarea library
|
||||
useDjarea,
|
||||
useDjareaStatus,
|
||||
// Re-exports from mizan library
|
||||
usemizan,
|
||||
usemizanStatus,
|
||||
usePush,
|
||||
DjangoError,
|
||||
type ConnectionStatus,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* E2E Test Fixtures
|
||||
*
|
||||
* Each fixture uses GENERATED Djarea hooks (not raw call()).
|
||||
* Each fixture uses GENERATED mizan hooks (not raw call()).
|
||||
* Playwright reads the DOM to verify behavior.
|
||||
*
|
||||
* URL hash selects the fixture: #echo, #add, #multiply, etc.
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
// Generated typed hooks — the actual Djarea API
|
||||
// Generated typed hooks — the actual mizan API
|
||||
import {
|
||||
DjangoContext,
|
||||
useEcho,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
usePermissionCheckFn,
|
||||
useCurrentUser,
|
||||
DjangoError,
|
||||
useDjarea,
|
||||
useMizan,
|
||||
} from './api/generated.django'
|
||||
import { useContactForm, useLoginForm } from './api/generated.forms'
|
||||
import { useChatChannel } from './api/generated.channels.hooks'
|
||||
@@ -121,7 +121,7 @@ function Multiply() {
|
||||
|
||||
function NotFound() {
|
||||
// Deliberately call a non-existent function via the raw primitive
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
const [error, setError] = useState<unknown>()
|
||||
useEffect(() => { call('does_not_exist').catch(setError) }, [call])
|
||||
return <Result error={error} />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DjangoContext baseUrl="/api/djarea">
|
||||
<DjangoContext baseUrl="/api/mizan">
|
||||
<Fixtures />
|
||||
</DjangoContext>
|
||||
)
|
||||
|
||||
@@ -8,17 +8,17 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'djarea/channels': path.join(reactPkg, 'channels/index.ts'),
|
||||
'djarea/client/react': path.join(reactPkg, 'client/react.ts'),
|
||||
'djarea/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'),
|
||||
'djarea/client': path.join(reactPkg, 'client/index.ts'),
|
||||
'djarea/jwt': path.join(reactPkg, 'jwt/index.ts'),
|
||||
'djarea/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'),
|
||||
'djarea/allauth': path.join(reactPkg, 'allauth/index.ts'),
|
||||
'djarea': path.join(reactPkg, 'index.ts'),
|
||||
'@rythazhur/djarea/channels': path.join(reactPkg, 'channels/index.ts'),
|
||||
'@rythazhur/djarea/jwt': path.join(reactPkg, 'jwt/index.ts'),
|
||||
'@rythazhur/djarea': path.join(reactPkg, 'index.ts'),
|
||||
'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
|
||||
'mizan/client/react': path.join(reactPkg, 'client/react.ts'),
|
||||
'mizan/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'),
|
||||
'mizan/client': path.join(reactPkg, 'client/index.ts'),
|
||||
'mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
|
||||
'mizan/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'),
|
||||
'mizan/allauth': path.join(reactPkg, 'allauth/index.ts'),
|
||||
'mizan': path.join(reactPkg, 'index.ts'),
|
||||
'@rythazhur/mizan/channels': path.join(reactPkg, 'channels/index.ts'),
|
||||
'@rythazhur/mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
|
||||
'@rythazhur/mizan': path.join(reactPkg, 'index.ts'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -6,4 +6,4 @@ class TestAppConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
||||
def ready(self):
|
||||
import testapp.djarea_clients # noqa: F401
|
||||
import testapp.mizan_clients # noqa: F401
|
||||
|
||||
@@ -6,9 +6,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
from mizan import wrap_asgi
|
||||
|
||||
# Register server functions and channels before building the ASGI app
|
||||
import testapp.djarea_clients # noqa: F401
|
||||
import testapp.mizan_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
|
||||
@@ -11,12 +11,12 @@ from django import forms
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import ServerFunction, client
|
||||
from djarea.channels import ReactChannel
|
||||
from djarea.setup.registry import register, register_form, register_as
|
||||
from djarea.channels import register as register_channel
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from djarea.jwt import jwt_obtain, jwt_refresh
|
||||
from mizan.client import ServerFunction, client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.setup.registry import register, register_form, register_as
|
||||
from mizan.channels import register as register_channel
|
||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
||||
from mizan.jwt import jwt_obtain, jwt_refresh
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -57,9 +57,9 @@ class WhoamiOutput(BaseModel):
|
||||
@client(auth=True)
|
||||
def whoami(request: HttpRequest) -> WhoamiOutput:
|
||||
return WhoamiOutput(
|
||||
user_id=getattr(request.user, 'id', None),
|
||||
email=getattr(request.user, 'email', ''),
|
||||
is_staff=getattr(request.user, 'is_staff', False),
|
||||
user_id=getattr(request.user, "id", None),
|
||||
email=getattr(request.user, "email", ""),
|
||||
is_staff=getattr(request.user, "is_staff", False),
|
||||
)
|
||||
|
||||
|
||||
@@ -197,18 +197,20 @@ register_channel(PresenceChannel, "presence")
|
||||
|
||||
|
||||
# --- Staff-only ---
|
||||
@client(auth='staff')
|
||||
@client(auth="staff")
|
||||
def staff_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message=f"staff:{request.user.email}")
|
||||
|
||||
|
||||
register(staff_only, "staff_only")
|
||||
|
||||
|
||||
# --- Superuser-only ---
|
||||
@client(auth='superuser')
|
||||
@client(auth="superuser")
|
||||
def superuser_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message=f"superuser:{request.user.email}")
|
||||
|
||||
|
||||
register(superuser_only, "superuser_only")
|
||||
|
||||
|
||||
@@ -216,12 +218,14 @@ register(superuser_only, "superuser_only")
|
||||
def check_verified_email(request):
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
return getattr(request.user, 'email', '').endswith('@verified.com')
|
||||
return getattr(request.user, "email", "").endswith("@verified.com")
|
||||
|
||||
|
||||
@client(auth=check_verified_email)
|
||||
def verified_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message="verified")
|
||||
|
||||
|
||||
register(verified_only, "verified_only")
|
||||
|
||||
|
||||
@@ -235,7 +239,8 @@ class CurrentUserOutput(BaseModel):
|
||||
email: str
|
||||
is_staff: bool
|
||||
|
||||
@client(context='global')
|
||||
|
||||
@client(context="global")
|
||||
def current_user(request: HttpRequest) -> CurrentUserOutput:
|
||||
if request.user.is_authenticated:
|
||||
return CurrentUserOutput(
|
||||
@@ -245,16 +250,19 @@ def current_user(request: HttpRequest) -> CurrentUserOutput:
|
||||
)
|
||||
return CurrentUserOutput(authenticated=False, email="", is_staff=False)
|
||||
|
||||
|
||||
register(current_user, "current_user")
|
||||
|
||||
|
||||
class GreetOutput(BaseModel):
|
||||
greeting: str
|
||||
|
||||
@client(context='local')
|
||||
|
||||
@client(context="local")
|
||||
def greet(request: HttpRequest, name: str) -> GreetOutput:
|
||||
return GreetOutput(greeting=f"Hello, {name}!")
|
||||
|
||||
|
||||
register(greet, "greet")
|
||||
|
||||
|
||||
@@ -267,9 +275,11 @@ class MultiplyInput(BaseModel):
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
class MultiplyOutput(BaseModel):
|
||||
product: int
|
||||
|
||||
|
||||
@register_as("multiply")
|
||||
class Multiply(ServerFunction):
|
||||
Input = MultiplyInput
|
||||
@@ -288,6 +298,7 @@ class Multiply(ServerFunction):
|
||||
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise NotImplementedError("This feature is not yet implemented")
|
||||
|
||||
|
||||
register(not_implemented_fn, "not_implemented_fn")
|
||||
|
||||
|
||||
@@ -295,6 +306,7 @@ register(not_implemented_fn, "not_implemented_fn")
|
||||
def buggy_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise RuntimeError("Unexpected internal failure")
|
||||
|
||||
|
||||
register(buggy_fn, "buggy_fn")
|
||||
|
||||
|
||||
@@ -304,6 +316,7 @@ def permission_check_fn(request: HttpRequest, secret: str) -> EchoOutput:
|
||||
raise PermissionError("Wrong secret")
|
||||
return EchoOutput(message="access granted")
|
||||
|
||||
|
||||
register(permission_check_fn, "permission_check_fn")
|
||||
|
||||
|
||||
@@ -315,21 +328,22 @@ register(permission_check_fn, "permission_check_fn")
|
||||
@client(websocket=True, auth=True)
|
||||
def ws_whoami(request: HttpRequest) -> WhoamiOutput:
|
||||
return WhoamiOutput(
|
||||
user_id=getattr(request.user, 'id', None),
|
||||
email=getattr(request.user, 'email', ''),
|
||||
is_staff=getattr(request.user, 'is_staff', False),
|
||||
user_id=getattr(request.user, "id", None),
|
||||
email=getattr(request.user, "email", ""),
|
||||
is_staff=getattr(request.user, "is_staff", False),
|
||||
)
|
||||
|
||||
|
||||
register(ws_whoami, "ws_whoami")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DjareaFormMixin Forms
|
||||
# mizanFormMixin Forms
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ContactForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
subtitle="We'd love to hear from you",
|
||||
@@ -351,8 +365,8 @@ class ContactForm(DjareaFormMixin, forms.Form):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ItemForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
class ItemForm(mizanFormMixin, forms.Form):
|
||||
mizan = mizanFormMeta(
|
||||
name="item",
|
||||
title="Items",
|
||||
submit_label="Save Items",
|
||||
@@ -363,7 +377,10 @@ class ItemForm(DjareaFormMixin, forms.Form):
|
||||
quantity = forms.IntegerField(min_value=1, label="Quantity")
|
||||
|
||||
def on_submit_success(self, request):
|
||||
return {"label": self.cleaned_data["label"], "qty": self.cleaned_data["quantity"]}
|
||||
return {
|
||||
"label": self.cleaned_data["label"],
|
||||
"qty": self.cleaned_data["quantity"],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -376,11 +393,12 @@ class PrivateChannel(ReactChannel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return getattr(self.user, 'is_authenticated', False)
|
||||
return getattr(self.user, "is_authenticated", False)
|
||||
|
||||
def group(self, params=None):
|
||||
return "private_global"
|
||||
|
||||
|
||||
register_channel(PrivateChannel, "private")
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"djarea",
|
||||
"mizan",
|
||||
"testapp",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
path("api/mizan/", include("mizan.urls")),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "djarea",
|
||||
"name": "mizan",
|
||||
"version": "1.0.0",
|
||||
"description": "Django + React server functions framework.",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# @rythazhur/djarea (TypeScript)
|
||||
# @rythazhur/mizan (TypeScript)
|
||||
|
||||
React client for the Djarea framework. See the [monorepo root](../README.md) for full documentation.
|
||||
React client for the mizan framework. See the [monorepo root](../README.md) for full documentation.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @rythazhur/djarea@git+https://git.impactsoundworks.com/isw/djarea.git#workspace=react
|
||||
npm install @rythazhur/mizan@git+https://git.impactsoundworks.com/isw/mizan.git#workspace=react
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -30,8 +30,8 @@ export default {
|
||||
### 2. Generate
|
||||
|
||||
```bash
|
||||
npx djarea-generate # once
|
||||
npx djarea-generate --watch # dev mode
|
||||
npx mizan-generate # once
|
||||
npx mizan-generate --watch # dev mode
|
||||
```
|
||||
|
||||
### 3. Wrap your app
|
||||
@@ -74,7 +74,7 @@ chat.messages // typed, reactive
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `generated.django.tsx` | `DjangoContext` + typed hooks |
|
||||
| `generated.djarea.ts` | Pydantic types |
|
||||
| `generated.mizan.ts` | Pydantic types |
|
||||
| `generated.forms.ts` | Form hooks with Zod |
|
||||
| `generated.channels.hooks.tsx` | Channel hooks |
|
||||
| `index.ts` | Re-exports everything |
|
||||
@@ -83,11 +83,11 @@ chat.messages // typed, reactive
|
||||
|
||||
| Import | When to use |
|
||||
|--------|------------|
|
||||
| `@rythazhur/djarea` | Core: DjareaProvider, hooks, forms, errors |
|
||||
| `@rythazhur/djarea/channels` | WebSocket channels |
|
||||
| `@rythazhur/djarea/jwt` | JWT token management |
|
||||
| `@rythazhur/djarea/client` | HTTP clients (CSR/SSR) |
|
||||
| `@rythazhur/djarea/allauth` | Allauth UI components |
|
||||
| `@rythazhur/mizan` | Core: mizanProvider, hooks, forms, errors |
|
||||
| `@rythazhur/mizan/channels` | WebSocket channels |
|
||||
| `@rythazhur/mizan/jwt` | JWT token management |
|
||||
| `@rythazhur/mizan/client` | HTTP clients (CSR/SSR) |
|
||||
| `@rythazhur/mizan/allauth` | Allauth UI components |
|
||||
|
||||
These are **library internals** used by the generated code. You should import from `@/api` (your generated index), not from the library directly.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@rythazhur/djarea",
|
||||
"name": "@rythazhur/mizan",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"djarea-generate": "./dist/generator/cli.mjs"
|
||||
"mizan-generate": "./dist/generator/cli.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('src/generator','dist/generator',{recursive:true})\"",
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, act } from '@testing-library/react'
|
||||
import {
|
||||
DjareaProvider,
|
||||
useDjarea,
|
||||
useDjareaStatus,
|
||||
useDjareaCall,
|
||||
MizanProvider,
|
||||
useMizan,
|
||||
useMizanStatus,
|
||||
useMizanCall,
|
||||
// Legacy aliases for backwards compatibility tests
|
||||
DjangoContext,
|
||||
useDjango,
|
||||
@@ -27,18 +27,18 @@ import { describeIntegration, BACKEND_URL } from '../testing'
|
||||
// Unit Tests (no backend required)
|
||||
// ============================================================================
|
||||
|
||||
describe('Djarea Context (unit)', () => {
|
||||
describe('useDjarea hook', () => {
|
||||
describe('mizan Context (unit)', () => {
|
||||
describe('useMizan hook', () => {
|
||||
it('should throw when used outside provider', () => {
|
||||
function TestComponent() {
|
||||
useDjarea()
|
||||
useMizan()
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
|
||||
|
||||
expect(() => render(<TestComponent />)).toThrow(
|
||||
'useDjarea must be used within a DjareaProvider'
|
||||
'useMizan must be used within a MizanProvider'
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
@@ -48,14 +48,14 @@ describe('Djarea Context (unit)', () => {
|
||||
let contextValue: any = null
|
||||
|
||||
function TestComponent() {
|
||||
contextValue = useDjarea()
|
||||
contextValue = useMizan()
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<DjareaProvider autoConnect={false}>
|
||||
<MizanProvider autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjareaProvider>
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
expect(contextValue).not.toBeNull()
|
||||
@@ -63,17 +63,17 @@ describe('Djarea Context (unit)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDjareaStatus hook', () => {
|
||||
describe('useMizanStatus hook', () => {
|
||||
it('should return disconnected when autoConnect is false', () => {
|
||||
function TestComponent() {
|
||||
const status = useDjareaStatus()
|
||||
const status = useMizanStatus()
|
||||
return <div data-testid="status">{status}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<DjareaProvider autoConnect={false}>
|
||||
<MizanProvider autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjareaProvider>
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status')).toHaveTextContent('disconnected')
|
||||
@@ -85,7 +85,7 @@ describe('Djarea Context (unit)', () => {
|
||||
let contextValue: any = null
|
||||
|
||||
function TestComponent() {
|
||||
contextValue = useDjarea()
|
||||
contextValue = useMizan()
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
@@ -95,9 +95,9 @@ describe('Djarea Context (unit)', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<DjareaProvider hydration={hydration} autoConnect={false}>
|
||||
<MizanProvider hydration={hydration} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjareaProvider>
|
||||
</MizanProvider>
|
||||
)
|
||||
|
||||
expect(contextValue.getContext('auth_status')).toEqual({ is_authenticated: false })
|
||||
@@ -110,7 +110,7 @@ describe('Djarea Context (unit)', () => {
|
||||
// Integration Tests (require running backend)
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('Djarea Context (integration)', () => {
|
||||
describeIntegration('mizan Context (integration)', () => {
|
||||
describe('server function calls via HTTP', () => {
|
||||
it('should call echo function and get response', async () => {
|
||||
let result: any = null
|
||||
@@ -130,7 +130,7 @@ describeIntegration('Djarea Context (integration)', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjangoContext>
|
||||
)
|
||||
@@ -161,7 +161,7 @@ describeIntegration('Djarea Context (integration)', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjangoContext>
|
||||
)
|
||||
@@ -192,7 +192,7 @@ describeIntegration('Djarea Context (integration)', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjangoContext>
|
||||
)
|
||||
@@ -227,7 +227,7 @@ describeIntegration('Djarea Context (integration)', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjangoContext>
|
||||
)
|
||||
@@ -260,7 +260,7 @@ describeIntegration('Djarea Context (integration)', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjangoContext>
|
||||
)
|
||||
@@ -296,7 +296,7 @@ describeIntegration('Djarea Context (integration)', () => {
|
||||
}
|
||||
|
||||
render(
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
<TestComponent />
|
||||
</DjangoContext>
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ function renderFormHook<TData extends Record<string, unknown>>(
|
||||
) {
|
||||
return renderHook(() => useDjangoFormCore<TData>(config), {
|
||||
wrapper: ({ children }) => (
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
{children}
|
||||
</DjangoContext>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Cross-cutting integration tests for djarea
|
||||
* Cross-cutting integration tests for mizan
|
||||
*
|
||||
* Tests error paths and protocol correctness across HTTP, Forms, and WebSocket.
|
||||
* Requires a running backend: docker-compose up
|
||||
@@ -10,22 +10,22 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { describeIntegration, BACKEND_URL, WS_URL } from '../testing'
|
||||
import { DjareaProvider, useDjarea } from '../context'
|
||||
import { MizanProvider, useMizan } from '../context'
|
||||
import { DjangoError } from '../errors'
|
||||
import { ChannelConnection } from '../channels/connection'
|
||||
import { RPCError } from '../channels/connection'
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DjareaProvider baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
|
||||
<MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
|
||||
{children}
|
||||
</DjareaProvider>
|
||||
</MizanProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to get call function
|
||||
function useCall() {
|
||||
const { call } = useDjarea()
|
||||
const { call } = useMizan()
|
||||
return call
|
||||
}
|
||||
|
||||
@@ -503,7 +503,7 @@ describeIntegration('Error code coverage', () => {
|
||||
})
|
||||
|
||||
it('should return BAD_REQUEST for invalid JSON body', async () => {
|
||||
const response = await fetch(`${BACKEND_URL}/api/djarea/call/`, {
|
||||
const response = await fetch(`${BACKEND_URL}/api/mizan/call/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -515,7 +515,7 @@ describeIntegration('Error code coverage', () => {
|
||||
})
|
||||
|
||||
it('should return BAD_REQUEST for missing fn field', async () => {
|
||||
const response = await fetch(`${BACKEND_URL}/api/djarea/call/`, {
|
||||
const response = await fetch(`${BACKEND_URL}/api/mizan/call/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -528,11 +528,11 @@ describeIntegration('Error code coverage', () => {
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Group 8: DjareaFormMixin integration
|
||||
// Group 8: mizanFormMixin integration
|
||||
// ============================================================================
|
||||
|
||||
describeIntegration('DjareaFormMixin integration', () => {
|
||||
it('should return schema with title, subtitle, and submit_label from DjareaFormMeta', async () => {
|
||||
describeIntegration('mizanFormMixin integration', () => {
|
||||
it('should return schema with title, subtitle, and submit_label from mizanFormMeta', async () => {
|
||||
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
|
||||
|
||||
let response: any = null
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Re-export RouterAdapter from djarea/client.
|
||||
* Re-export RouterAdapter from mizan/client.
|
||||
*
|
||||
* Allauth extends this with a required getParam method.
|
||||
*/
|
||||
import type { RouterAdapter as BaseRouterAdapter } from 'djarea/client'
|
||||
import type { RouterAdapter as BaseRouterAdapter } from 'mizan/client'
|
||||
|
||||
export interface RouterAdapter extends BaseRouterAdapter {
|
||||
/** Get a specific route param (e.g., from /auth/[...path]) - required for allauth */
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type DjangoFormState,
|
||||
type FormOptions,
|
||||
type FormErrors,
|
||||
} from 'djarea'
|
||||
} from 'mizan'
|
||||
import { useAuthContext } from '../contexts/AuthContext'
|
||||
import { useStyles } from '../contexts/StylesContext'
|
||||
import { getAuthDetails, AuthDetails } from '../api'
|
||||
@@ -41,7 +41,7 @@ interface AuthDjangoFormProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthDjangoForm renders a form from the Djarea server functions
|
||||
* AuthDjangoForm renders a form from the mizan server functions
|
||||
* with styling consistent with the auth UI.
|
||||
*
|
||||
* It fetches the form schema (including title, subtitle, fields, submit label)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
import { useDjangoFormCore } from 'djarea'
|
||||
import { useDjangoFormCore } from 'mizan'
|
||||
import { SettingsSection, SettingsItem, SettingsList, Badge, Button } from './SettingsComponents'
|
||||
|
||||
interface Email {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useDjangoFormCore } from 'djarea'
|
||||
import { useDjangoFormCore } from 'mizan'
|
||||
import { useStyles } from '../../contexts/StylesContext'
|
||||
import { SettingsSection, Button } from './SettingsComponents'
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 1. Define the base path for Django-initiated routes (must match HEADLESS_FRONTEND_URLS)
|
||||
* 2. Define where to navigate for various auth events (developer controls these)
|
||||
*
|
||||
* For JWT-based API calls, use djarea/jwt separately.
|
||||
* For JWT-based API calls, use mizan/jwt separately.
|
||||
*/
|
||||
|
||||
export interface AllauthConfig {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useDjangoCSRClient, Auth } from 'djarea/client/react'
|
||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
import { useAuthContext } from './AuthContext'
|
||||
import { createAPI, AllauthAPI, BrowserFormAction } from '../api'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useDjangoCSRClient, Auth } from 'djarea/client/react'
|
||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
import type { RouterAdapter } from '../adapters/router'
|
||||
import type { InitialAuth } from '../hydration'
|
||||
import { AuthContext } from './AuthContext'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useDjangoCSRClient, Auth } from 'djarea/client/react'
|
||||
import { useDjarea, useDjareaContext } from 'djarea'
|
||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
||||
import { useMizan, useMizanContext } from 'mizan'
|
||||
import { getAuthDetails, createAPI } from '../api'
|
||||
import type { AllauthResponse } from '../types'
|
||||
import getAuthChangeEvent from '../events'
|
||||
@@ -30,7 +30,7 @@ export function AuthContext({
|
||||
auth: initialAuth,
|
||||
}: AuthContextProps) {
|
||||
const client = useDjangoCSRClient(Auth.SESSION)
|
||||
const { refreshAllContexts } = useDjarea()
|
||||
const { refreshAllContexts } = useMizan()
|
||||
const [auth, setAuth] = useState(initialAuth)
|
||||
const [event, setEvent] = useState('')
|
||||
const prevAuth = useRef(initialAuth)
|
||||
@@ -100,10 +100,10 @@ export interface AllauthUser {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user from DjareaProvider.
|
||||
* Get the current user from MizanProvider.
|
||||
*
|
||||
* This uses the generic djarea hook to access the 'user' context.
|
||||
* The backend defines this context in lib/djarea/allauth/contexts.py:
|
||||
* This uses the generic mizan hook to access the 'user' context.
|
||||
* The backend defines this context in lib/mizan/allauth/contexts.py:
|
||||
*
|
||||
* @client(context='global')
|
||||
* def user(request) -> UserOutput | None:
|
||||
@@ -112,7 +112,7 @@ export interface AllauthUser {
|
||||
* @typeParam T - User type (defaults to AllauthUser, products can use more specific types)
|
||||
*/
|
||||
export function useUser<T extends AllauthUser = AllauthUser>(): T {
|
||||
const user = useDjareaContext<T>('user')
|
||||
const user = useMizanContext<T>('user')
|
||||
// Return empty object cast to T if user is undefined (not loaded)
|
||||
// This matches the previous behavior and allows optional chaining
|
||||
return (user ?? {}) as T
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DjangoHTTPClient } from 'djarea/client'
|
||||
import type { DjangoHTTPClient } from 'mizan/client'
|
||||
import { createAPI } from './api'
|
||||
import type { AllauthResponse } from './types'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* djarea/allauth
|
||||
* mizan/allauth
|
||||
*
|
||||
* React integration for django-allauth headless API.
|
||||
* Framework-agnostic - works with Next.js, Remix, React Router, etc.
|
||||
@@ -9,9 +9,9 @@
|
||||
* ```tsx
|
||||
* // layout.tsx
|
||||
* import { cookies } from 'next/headers'
|
||||
* import { createDjangoSSRClient } from 'djarea/client'
|
||||
* import { getInitialAuth } from 'djarea/allauth'
|
||||
* import { NextAllauthContext } from 'djarea/allauth/nextjs'
|
||||
* import { createDjangoSSRClient } from 'mizan/client'
|
||||
* import { getInitialAuth } from 'mizan/allauth'
|
||||
* import { NextAllauthContext } from 'mizan/allauth/nextjs'
|
||||
*
|
||||
* export default async function RootLayout({ children }) {
|
||||
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() })
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Next.js adapter for djarea/allauth.
|
||||
* Next.js adapter for mizan/allauth.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* // In layout.tsx (server component)
|
||||
* import { createDjangoSSRClient } from 'djarea/client'
|
||||
* import { getInitialAuth } from 'djarea/allauth'
|
||||
* import { NextAllauthContext } from 'djarea/allauth/nextjs'
|
||||
* import { createDjangoSSRClient } from 'mizan/client'
|
||||
* import { getInitialAuth } from 'mizan/allauth'
|
||||
* import { NextAllauthContext } from 'mizan/allauth/nextjs'
|
||||
*
|
||||
* export default async function RootLayout({ children }) {
|
||||
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* WebSocket connection manager for djarea/channels
|
||||
* WebSocket connection manager for mizan/channels
|
||||
*
|
||||
* Supports both pub/sub channels AND RPC calls over the same connection.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* React context for djarea/channels
|
||||
* React context for mizan/channels
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* React hooks for djarea/channels
|
||||
* React hooks for mizan/channels
|
||||
*
|
||||
* Includes pub/sub channel hooks AND RPC hooks.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* djarea/channels
|
||||
* mizan/channels
|
||||
*
|
||||
* Real-time WebSocket communication with Django Channels.
|
||||
* Type-safe bidirectional messaging.
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* ```tsx
|
||||
* // layout.tsx
|
||||
* import { ChannelProvider } from 'djarea/channels'
|
||||
* import { ChannelProvider } from 'mizan/channels'
|
||||
*
|
||||
* export default function Layout({ children }) {
|
||||
* return (
|
||||
@@ -36,7 +36,7 @@
|
||||
*
|
||||
* ```tsx
|
||||
* // Using raw hook (for custom channels)
|
||||
* import { useChannel } from 'djarea/channels'
|
||||
* import { useChannel } from 'mizan/channels'
|
||||
*
|
||||
* function CustomChannel() {
|
||||
* const channel = useChannel<
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Types for djarea/channels
|
||||
* Types for mizan/channels
|
||||
*/
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user