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:
2026-03-31 20:01:03 -04:00
parent bf837e598b
commit c866142770
118 changed files with 1778 additions and 1433 deletions

View File

@@ -7,7 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
&& rm -rf /var/lib/apt/lists/* && 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/ COPY django/ /app/django/
RUN pip install --no-cache-dir /app/django[channels] daphne RUN pip install --no-cache-dir /app/django[channels] daphne

View File

@@ -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 reviewing the full codebase, the original @compose discussion from January 2025, and
several rounds of architectural refinement. Treat this as the spec. 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 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. **Maison** — it lives inside Mizan and does not need its own public surface.

View File

@@ -20,7 +20,7 @@ test-react:
test-integration: docker-up test-integration: docker-up
@echo "Waiting for backend..." @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 cd react && npm run test:integration
@$(MAKE) docker-down @$(MAKE) docker-down
@@ -41,6 +41,6 @@ test-all: test test-integration
clean: clean:
docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true 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 -rf react/dist react/node_modules
rm -f example/db.sqlite3 rm -f example/db.sqlite3

335
README.md
View File

@@ -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. You define Python functions. mizan generates typed React hooks. No API routes, no serializers, no endpoint boilerplate.
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.
```python ```python
@client # Django
def current_user(request) -> UserShape: @client(context='global')
return UserShape.query(lambda qs: qs.filter(pk=request.user.pk))[0] def current_user(request) -> UserOutput:
return UserOutput(email=request.user.email)
``` ```
```tsx ```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 ## Quick Start
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
### 1. Django setup ### 1. Django setup
```python ```python
# settings.py # settings.py
INSTALLED_APPS = [ INSTALLED_APPS = [
"djarea", "mizan",
"myapp", "myapp",
] ]
# urls.py # urls.py
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("api/djarea/", include("djarea.urls")), path("api/mizan/", include("mizan.urls")),
] ]
# asgi.py (for WebSocket support) # asgi.py (for WebSocket support)
from djarea import wrap_asgi from mizan import wrap_asgi
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
``` ```
### 2. Define your client functions ### 2. Define server functions
```python ```python
# myapp/clients.py # myapp/mizan_clients.py
from djarea.client import client from django.http import HttpRequest
from djarea.shapes import Shape from mizan.client import client
from mizan.setup.registry import register
from pydantic import BaseModel from pydantic import BaseModel
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
message: str message: str
@client @client
def echo(request, text: str) -> EchoOutput: def echo(request: HttpRequest, text: str) -> EchoOutput:
return EchoOutput(message=text) 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 ### 4. Generate TypeScript
// django.config.mjs
```bash
# django.config.mjs
export default { export default {
source: { source: {
django: { 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 ```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 ```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 }) { export default function Layout({ children }) {
return <DjangoContext>{children}</DjangoContext> return <DjangoContext>{children}</DjangoContext>
} }
```
```tsx
// page.tsx // page.tsx
import { useEcho, useCurrentUser, DjangoError } from '@/api'
function MyComponent() { function MyComponent() {
const user = useCurrentUser() const user = useCurrentUser()
const echo = useEcho() const echo = useEcho()
@@ -127,80 +121,91 @@ function MyComponent() {
console.log(result.message) // typed console.log(result.message) // typed
} catch (e) { } catch (e) {
if (e instanceof DjangoError) { if (e instanceof DjangoError) {
console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc. console.log(e.code) // NOT_FOUND, VALIDATION_ERROR, etc.
e.getFieldErrors('email') // field-level errors
} }
} }
} }
} }
``` ```
## 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 ## Architecture
# Full detail page — joins books with chapters
class AuthorDetailShape(Shape[Author]):
id: int | None = None
name: str
bio: str
books: list[BookShape] = []
# Dropdown menu — two columns, no joins ```
class FlatAuthorShape(Shape[Author]): React app
id: int | None = None └─ <DjangoContext> ← generated provider (includes ChannelProvider)
name: str ├─ 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 The generated `DjangoContext` is the **only provider** needed. It wraps `mizanProvider` + `ChannelProvider` and handles session init, CSRF, context auto-fetching, and WebSocket connection.
# Detail page: SELECT id, name, bio + prefetch books
authors = AuthorDetailShape.query()
# Dropdown: SELECT id, name. That's it. ## Code Generation
authors = FlatAuthorShape.query()
`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: Error codes: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `BAD_REQUEST`, `INTERNAL_ERROR`, `NOT_IMPLEMENTED`.
```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 |
## Forms ## Forms
Django forms become typed React hooks with client-side Zod validation: Django forms get typed React hooks with client-side Zod validation:
```python ```python
class ContactForm(DjareaFormMixin, forms.Form): # Django
djarea = DjareaFormMeta( class ContactForm(mizanFormMixin, forms.Form):
mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
submit_label="Send", submit_label="Send",
@@ -216,22 +221,22 @@ class ContactForm(DjareaFormMixin, forms.Form):
``` ```
```tsx ```tsx
// React (generated)
const form = useContactForm() const form = useContactForm()
form.schema // field metadata, title, submit label form.schema // { fields: { name: {...}, email: {...} }, title, submit_label }
form.data // { name: '', email: '', message: '' } form.data // { name: '', email: '', message: '' }
form.set('email', v) // typed setter form.set('email', v) // typed setter
form.errors // field-level errors (Zod + server) form.errors // field-level errors (Zod + server)
form.submit() // → { success: true, data: { sent: true } } 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 ## Channels
WebSocket channels with typed messages: WebSocket channels with typed messages:
```python ```python
# Django
class ChatChannel(ReactChannel): class ChatChannel(ReactChannel):
class Params(BaseModel): class Params(BaseModel):
room: str room: str
@@ -252,6 +257,7 @@ class ChatChannel(ReactChannel):
``` ```
```tsx ```tsx
// React (generated)
const chat = useChatChannel({ room: 'general' }) const chat = useChatChannel({ room: 'general' })
chat.status // 'connecting' | 'connected' | 'disconnected' chat.status // 'connecting' | 'connected' | 'disconnected'
@@ -259,111 +265,32 @@ chat.messages // ChatDjangoMessage[]
chat.send({ text: 'hello' }) 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 ## Testing
```bash ```bash
# Django # Django unit tests
cd django && uv run pytest cd django && uv sync --extra dev --extra channels && uv run pytest
# React # React unit tests
cd react && npm test 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 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 make test-all
``` ```
## Project structure ## Project Structure
``` ```
djarea/ mizan/
django/ Python package django/ Python package (mizan)
react/ TypeScript package react/ TypeScript package (@rythazhur/mizan)
example/ Integration test backend example/ Integration test backend (Docker)
e2e/ Playwright E2E tests desktop/ PyWebView desktop test app
e2e/ Playwright E2E tests + React harness
Makefile Test orchestration 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

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python #!/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. 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 import os
@@ -63,7 +63,7 @@ def main():
base_url = f"http://{host}:{port}" 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) print("ERROR: Django server failed to start", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -83,7 +83,7 @@ def main():
import webview import webview
window = webview.create_window( window = webview.create_window(
title="Djarea Desktop", title="mizan Desktop",
url=base_url, url=base_url,
width=1024, width=1024,
height=768, height=768,

View File

@@ -6,8 +6,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
django.setup() django.setup()
from django.core.asgi import get_asgi_application 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()) application = wrap_asgi(get_asgi_application())

View File

@@ -1,7 +1,7 @@
""" """
Desktop RPC server functions. Desktop RPC server functions.
Tests Djarea's appropriateness for desktop apps: Tests mizan's appropriateness for desktop apps:
- Local file system access - Local file system access
- SQLite CRUD - SQLite CRUD
- System introspection - System introspection
@@ -20,10 +20,10 @@ from pathlib import Path
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client import client from mizan.client import client
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
from djarea.setup.registry import register from mizan.setup.registry import register
from djarea.channels import register as register_channel from mizan.channels import register as register_channel
# ============================================================================= # =============================================================================
@@ -40,12 +40,12 @@ class SystemInfoOutput(BaseModel):
home_dir: str home_dir: str
cwd: str cwd: str
cpu_count: int cpu_count: int
djarea_version: str mizan_version: str
@client(websocket=True) @client(websocket=True)
def system_info(request: HttpRequest) -> SystemInfoOutput: def system_info(request: HttpRequest) -> SystemInfoOutput:
import djarea import mizan
return SystemInfoOutput( return SystemInfoOutput(
os_name=platform.system(), os_name=platform.system(),
@@ -56,7 +56,7 @@ def system_info(request: HttpRequest) -> SystemInfoOutput:
home_dir=str(Path.home()), home_dir=str(Path.home()),
cwd=os.getcwd(), cwd=os.getcwd(),
cpu_count=os.cpu_count() or 1, 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 = [] entries = []
try: 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: try:
stat = entry.stat() stat = entry.stat()
entries.append(FileEntry( entries.append(
name=entry.name, FileEntry(
path=str(entry), name=entry.name,
is_dir=entry.is_dir(), path=str(entry),
size=stat.st_size if not entry.is_dir() else 0, is_dir=entry.is_dir(),
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(), size=stat.st_size if not entry.is_dir() else 0,
)) modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
)
)
except (PermissionError, OSError): except (PermissionError, OSError):
continue continue
except PermissionError: except PermissionError:
@@ -268,7 +272,9 @@ register(list_notes, "list_notes")
@client(websocket=True) @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 from backend.models import Note
note = Note.objects.create(title=title, content=content, pinned=pinned) 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 from django.conf import settings
return AppInfoOutput( return AppInfoOutput(
app_name="Djarea Desktop", app_name="mizan Desktop",
uptime_seconds=round(time.time() - _start_time, 2), uptime_seconds=round(time.time() - _start_time, 2),
db_path=str(settings.DATABASES["default"]["NAME"]), db_path=str(settings.DATABASES["default"]["NAME"]),
pid=os.getpid(), pid=os.getpid(),

View File

@@ -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, Runs entirely local: SQLite database, in-memory channel layer,
no external services required. no external services required.

View File

@@ -27,7 +27,7 @@ def serve_dist(request, path="index.html"):
urlpatterns = [ urlpatterns = [
path("api/djarea/", include("djarea.urls")), path("api/mizan/", include("mizan.urls")),
re_path(r"^(?P<path>assets/.+)$", serve_dist), re_path(r"^(?P<path>assets/.+)$", serve_dist),
path("favicon.ico", serve_dist, {"path": "favicon.ico"}), path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
path("", serve_dist), path("", serve_dist),

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Djarea Desktop</title> <title>mizan Desktop</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; } body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }

View File

@@ -1,5 +1,5 @@
{ {
"name": "djarea-desktop-frontend", "name": "mizan-desktop-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -7,7 +7,7 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"@rythazhur/djarea": "file:../../react", "@rythazhur/mizan": "file:../../react",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },

View File

@@ -1,10 +1,10 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { DjareaProvider, useDjarea, useDjareaStatus } from '@rythazhur/djarea' import { MizanProvider, useMizan, useMizanStatus } from '@rythazhur/mizan'
// ─── System Info ──────────────────────────────────────────────────────────── // ─── System Info ────────────────────────────────────────────────────────────
function SystemInfo() { function SystemInfo() {
const { call } = useDjarea() const { call } = useMizan()
const [info, setInfo] = useState<Record<string, unknown> | null>(null) const [info, setInfo] = useState<Record<string, unknown> | null>(null)
useEffect(() => { useEffect(() => {
@@ -33,7 +33,7 @@ function SystemInfo() {
// ─── Connection Status ────────────────────────────────────────────────────── // ─── Connection Status ──────────────────────────────────────────────────────
function StatusBar() { function StatusBar() {
const status = useDjareaStatus() const status = useMizanStatus()
return ( return (
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}> <div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
{status} {status}
@@ -46,7 +46,7 @@ function StatusBar() {
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string } type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
function Notes() { function Notes() {
const { call } = useDjarea() const { call } = useMizan()
const [notes, setNotes] = useState<Note[]>([]) const [notes, setNotes] = useState<Note[]>([])
const [selected, setSelected] = useState<Note | null>(null) const [selected, setSelected] = useState<Note | null>(null)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@@ -140,7 +140,7 @@ function Notes() {
type FileEntry = { name: string; path: string; is_dir: boolean; size: number } type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
function FileBrowser() { function FileBrowser() {
const { call } = useDjarea() const { call } = useMizan()
const [dir, setDir] = useState('~') const [dir, setDir] = useState('~')
const [entries, setEntries] = useState<FileEntry[]>([]) const [entries, setEntries] = useState<FileEntry[]>([])
const [parent, setParent] = useState<string | null>(null) const [parent, setParent] = useState<string | null>(null)
@@ -184,17 +184,17 @@ function FileBrowser() {
export function App() { export function App() {
return ( return (
<DjareaProvider baseUrl="/api/djarea" autoConnect={false}> <MizanProvider baseUrl="/api/mizan" autoConnect={false}>
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}> <div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 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 /> <StatusBar />
</div> </div>
<SystemInfo /> <SystemInfo />
<Notes /> <Notes />
<FileBrowser /> <FileBrowser />
</div> </div>
</DjareaProvider> </MizanProvider>
) )
} }

View File

@@ -1,16 +1,16 @@
[project] [project]
name = "djarea-desktop" name = "mizan-desktop"
version = "0.1.0" version = "0.1.0"
description = "Desktop integration test app for Djarea" description = "Desktop integration test app for mizan"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"djarea[channels]", "mizan[channels]",
"uvicorn[standard]>=0.30", "uvicorn[standard]>=0.30",
"pywebview[qt]>=5.0", "pywebview[qt]>=5.0",
] ]
[tool.uv.sources] [tool.uv.sources]
djarea = { path = "../django", editable = true } mizan = { path = "../django", editable = true }
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [

View File

@@ -1,7 +1,8 @@
import django import django
from django.conf import settings from django.conf import settings
# Ensure migrations run before tests # Ensure migrations run before tests
def pytest_configure(): def pytest_configure():
# Import djarea_clients to trigger function registration # Import mizan_clients to trigger function registration
import backend.djarea_clients # noqa: F401 import backend.mizan_clients # noqa: F401

View File

@@ -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. Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
Every test makes a real HTTP request — no mocks, no RequestFactory. Every test makes a real HTTP request — no mocks, no RequestFactory.
@@ -14,7 +14,7 @@ from django.test import LiveServerTestCase
class RealHTTPMixin: class RealHTTPMixin:
def _session_init(self): 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)) resp = urlopen(Request(url))
cookies = resp.headers.get_all("Set-Cookie") or [] cookies = resp.headers.get_all("Set-Cookie") or []
for cookie in cookies: for cookie in cookies:
@@ -26,7 +26,7 @@ class RealHTTPMixin:
self._cookies = "" self._cookies = ""
def _call(self, fn: str, args: dict | None = None): 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() body = json.dumps({"fn": fn, "args": args or {}}).encode()
req = Request(url, data=body, method="POST") req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
@@ -37,7 +37,13 @@ class RealHTTPMixin:
resp = urlopen(req) resp = urlopen(req)
return json.loads(resp.read()) 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.""" """Raw POST without the call() envelope — for testing malformed requests."""
url = f"{self.live_server_url}{path}" url = f"{self.live_server_url}{path}"
if isinstance(body, str): if isinstance(body, str):
@@ -55,7 +61,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
def test_session_endpoint_sets_csrf_cookie(self): def test_session_endpoint_sets_csrf_cookie(self):
"""GET /session/ must return a Set-Cookie with csrftoken.""" """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)) resp = urlopen(Request(url))
cookies = resp.headers.get_all("Set-Cookie") or [] cookies = resp.headers.get_all("Set-Cookie") or []
@@ -64,7 +70,7 @@ class CSRFTests(RealHTTPMixin, LiveServerTestCase):
def test_call_without_csrf_is_rejected(self): def test_call_without_csrf_is_rejected(self):
"""POST /call/ without CSRF token must fail.""" """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() body = json.dumps({"fn": "system_info", "args": {}}).encode()
req = Request(url, data=body, method="POST") req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
@@ -134,7 +140,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
def test_get_method_rejected(self): def test_get_method_rejected(self):
"""GET to /call/ should be rejected.""" """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: try:
resp = urlopen(Request(url)) resp = urlopen(Request(url))
data = json.loads(resp.read()) data = json.loads(resp.read())
@@ -147,7 +153,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
self._session_init() self._session_init()
try: try:
resp = self._raw_post( resp = self._raw_post(
"/api/djarea/call/", "/api/mizan/call/",
body="not valid json{{{", body="not valid json{{{",
include_csrf=True, include_csrf=True,
) )
@@ -162,7 +168,7 @@ class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
self._session_init() self._session_init()
try: try:
resp = self._raw_post( resp = self._raw_post(
"/api/djarea/call/", "/api/mizan/call/",
body=json.dumps({"not_fn": "hello"}), body=json.dumps({"not_fn": "hello"}),
include_csrf=True, include_csrf=True,
) )

View File

@@ -12,7 +12,7 @@ from urllib.request import urlopen, Request
class RealHTTPMixin: class RealHTTPMixin:
def _session_init(self): 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)) resp = urlopen(Request(url))
cookies = resp.headers.get_all("Set-Cookie") or [] cookies = resp.headers.get_all("Set-Cookie") or []
for cookie in cookies: for cookie in cookies:
@@ -24,7 +24,7 @@ class RealHTTPMixin:
self._cookies = "" self._cookies = ""
def _call(self, fn: str, args: dict | None = None): 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() body = json.dumps({"fn": fn, "args": args or {}}).encode()
req = Request(url, data=body, method="POST") req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
@@ -105,6 +105,7 @@ class NotesCRUDTests(RealHTTPMixin, LiveServerTestCase):
# Verify it's gone # Verify it's gone
from urllib.error import HTTPError from urllib.error import HTTPError
try: try:
get_data = self._call("get_note", {"id": note_id}) get_data = self._call("get_note", {"id": note_id})
self.assertTrue(get_data["error"]) self.assertTrue(get_data["error"])

View File

@@ -18,8 +18,8 @@ class RealHTTPMixin:
"""Makes real HTTP requests to the live server.""" """Makes real HTTP requests to the live server."""
def _session_init(self): def _session_init(self):
"""Hit /session/ to get CSRF cookie, like DjareaProvider does.""" """Hit /session/ to get CSRF cookie, like mizanProvider does."""
url = f"{self.live_server_url}/api/djarea/session/" url = f"{self.live_server_url}/api/mizan/session/"
req = Request(url) req = Request(url)
resp = urlopen(req) resp = urlopen(req)
# Extract csrftoken from Set-Cookie header # Extract csrftoken from Set-Cookie header
@@ -33,8 +33,8 @@ class RealHTTPMixin:
self._cookies = "" self._cookies = ""
def _call(self, fn: str, args: dict | None = None): def _call(self, fn: str, args: dict | None = None):
"""Make a real POST to /api/djarea/call/ with CSRF token.""" """Make a real POST to /api/mizan/call/ with CSRF token."""
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() body = json.dumps({"fn": fn, "args": args or {}}).encode()
req = Request(url, data=body, method="POST") req = Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
@@ -80,7 +80,7 @@ class SystemInfoTests(RealHTTPMixin, LiveServerTestCase):
data = self._call("app_info") data = self._call("app_info")
self.assertFalse(data["error"]) 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) self.assertGreater(data["data"]["uptime_seconds"], 0)
@@ -89,11 +89,12 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
def setUp(self): def setUp(self):
self._session_init() self._session_init()
self.test_dir = Path.home() / ".djarea-test" self.test_dir = Path.home() / ".mizan-test"
self.test_dir.mkdir(exist_ok=True) self.test_dir.mkdir(exist_ok=True)
def tearDown(self): def tearDown(self):
import shutil import shutil
if self.test_dir.exists(): if self.test_dir.exists():
shutil.rmtree(self.test_dir) shutil.rmtree(self.test_dir)
@@ -116,7 +117,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
test_content = "Hello from a REAL HTTP integration test!" test_content = "Hello from a REAL HTTP integration test!"
# Write # 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.assertFalse(write_data["error"])
self.assertEqual(write_data["data"]["path"], test_path) self.assertEqual(write_data["data"]["path"], test_path)
@@ -130,7 +133,9 @@ class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
from urllib.error import HTTPError from urllib.error import HTTPError
try: 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 # If we get here, check the response has an error
self.assertTrue(data["error"]) self.assertTrue(data["error"])
self.assertEqual(data["code"], "FORBIDDEN") self.assertEqual(data["code"], "FORBIDDEN")

View File

@@ -1,32 +1,32 @@
# djarea (Python) # mizan (Python)
Django server functions framework. See the [monorepo root](../README.md) for full documentation. Django server functions framework. See the [monorepo root](../README.md) for full documentation.
## Install ## Install
```bash ```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 ## Setup
```python ```python
# settings.py # settings.py
INSTALLED_APPS = ["djarea", ...] INSTALLED_APPS = ["mizan", ...]
# urls.py # urls.py
path("api/djarea/", include("djarea.urls")) path("api/mizan/", include("mizan.urls"))
# asgi.py (optional, for WebSocket) # asgi.py (optional, for WebSocket)
from djarea import wrap_asgi from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
``` ```
## Define Functions ## Define Functions
```python ```python
from djarea.client import client from mizan.client import client
from djarea.setup.registry import register from mizan.setup.registry import register
from pydantic import BaseModel from pydantic import BaseModel
class Output(BaseModel): class Output(BaseModel):
@@ -43,7 +43,7 @@ Register in `apps.py`:
```python ```python
def ready(self): def ready(self):
import myapp.djarea_clients import myapp.mizan_clients
``` ```
## Auth ## Auth
@@ -65,10 +65,10 @@ def ready(self):
## Forms ## Forms
```python ```python
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="contact", title="Contact Us") mizan = mizanFormMeta(name="contact", title="Contact Us")
name = forms.CharField() name = forms.CharField()
email = forms.EmailField() email = forms.EmailField()
@@ -81,7 +81,7 @@ Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Generates
## Channels ## Channels
```python ```python
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
class ChatChannel(ReactChannel): class ChatChannel(ReactChannel):
class Params(BaseModel): class Params(BaseModel):

View File

@@ -1,5 +1,5 @@
[project] [project]
name = "djarea" name = "mizan"
version = "1.0.1" version = "1.0.1"
description = "Django + React server functions framework" description = "Django + React server functions framework"
readme = "README.md" readme = "README.md"
@@ -36,11 +36,11 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/djarea"] packages = ["src/mizan"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings" DJANGO_SETTINGS_MODULE = "tests.settings"
pythonpath = ["src", "."] pythonpath = ["src", "."]
testpaths = ["src/djarea/tests"] testpaths = ["src/mizan/tests"]
python_classes = ["*Tests", "*Test", "Test*"] python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"] python_functions = ["test_*"]

View File

@@ -1,3 +0,0 @@
from djarea.shapes.core import Diff, NestedDiff, Shape
__all__ = ["Diff", "NestedDiff", "Shape"]

View File

@@ -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. 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 ### 1. urls.py - HTTP endpoint
```python ```python
from djarea import urls as djarea_urls from mizan import urls as mizan_urls
urlpatterns = [ urlpatterns = [
path('api/djarea/', include(djarea_urls)), path('api/mizan/', include(mizan_urls)),
] ]
``` ```
### 2. asgi.py - WebSocket support (optional) ### 2. asgi.py - WebSocket support (optional)
```python ```python
from djarea import wrap_asgi from mizan import wrap_asgi
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
@@ -25,7 +25,7 @@ application = wrap_asgi(get_asgi_application())
### 3. Define server functions ### 3. Define server functions
```python ```python
# apps/myapp/clients.py # apps/myapp/clients.py
from djarea import client from mizan import client
from pydantic import BaseModel from pydantic import BaseModel
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
@@ -51,8 +51,8 @@ def send_message(request, room_id: int, text: str) -> MessageOutput:
```python ```python
class MyAppConfig(AppConfig): class MyAppConfig(AppConfig):
def ready(self): def ready(self):
from djarea.setup import djarea_clients from mizan.setup import mizan_clients
djarea_clients('apps') mizan_clients('apps')
``` ```
### 5. Frontend - generate types and use ### 5. Frontend - generate types and use
@@ -76,7 +76,7 @@ await echo({ text: 'hello' })
| `@client(context='local')` | `<XxxProvider>` + hook| HTTP | | `@client(context='local')` | `<XxxProvider>` + hook| HTTP |
| `@client(websocket=True)` | `useXxx()` hook | WebSocket | | `@client(websocket=True)` | `useXxx()` hook | WebSocket |
| `@compose(...)` | `<XxxProvider>` combined | varies | | `@compose(...)` | `<XxxProvider>` combined | varies |
| `DjareaFormMixin` | `useXxxForm()` + Zod | HTTP | | `mizanFormMixin` | `useXxxForm()` + Zod | HTTP |
| `ReactChannel` | `useXxxChannel()` | WebSocket | | `ReactChannel` | `useXxxChannel()` | WebSocket |
""" """
@@ -89,11 +89,12 @@ from . import setup
from .channels import ReactChannel from .channels import ReactChannel
from .channels import register as register_channel from .channels import register as register_channel
from .client import ComposedContext, ServerFunction, client, compose 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 ( from .setup import (
djarea_clients, mizan_clients,
djarea_module, mizan_module,
get_channel, get_channel,
get_function, get_function,
register, register,
@@ -104,9 +105,9 @@ from .setup import (
def __getattr__(name): def __getattr__(name):
"""Lazy loading for modules that can't be imported at app load time.""" """Lazy loading for modules that can't be imported at app load time."""
if name == "urls": 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": if name == "Shape":
from .shapes import Shape from .shapes import Shape
@@ -116,11 +117,11 @@ def __getattr__(name):
def wrap_asgi(http_application): 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: Usage in asgi.py:
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
from djarea import wrap_asgi from mizan import wrap_asgi
application = wrap_asgi(get_asgi_application()) application = wrap_asgi(get_asgi_application())
@@ -162,8 +163,8 @@ __all__ = [
"ServerFunction", "ServerFunction",
"ComposedContext", "ComposedContext",
# Setup # Setup
"djarea_clients", "mizan_clients",
"djarea_module", "mizan_module",
"register", "register",
"register_as", "register_as",
"get_function", "get_function",

View File

@@ -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. Type-safe bidirectional messaging between Django and React via WebSockets.
Hooks are auto-generated with full TypeScript types. Hooks are auto-generated with full TypeScript types.
@@ -9,7 +9,7 @@ Hooks are auto-generated with full TypeScript types.
```python ```python
# channels.py # channels.py
from pydantic import BaseModel from pydantic import BaseModel
from djarea import channels from mizan import channels
class ChatChannel(channels.ReactChannel): class ChatChannel(channels.ReactChannel):
@@ -42,7 +42,7 @@ channels.register(ChatChannel, 'chat')
```python ```python
# asgi.py # asgi.py
from djarea import channels from mizan import channels
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": get_asgi_application(), "http": get_asgi_application(),
@@ -88,6 +88,7 @@ logger = logging.getLogger(__name__)
# Base Classes # Base Classes
# ============================================================================= # =============================================================================
class ReactChannel: class ReactChannel:
""" """
Base class for WebSocket channels. Base class for WebSocket channels.
@@ -140,9 +141,7 @@ class ReactChannel:
Messages returned from receive() are broadcast to this group. Messages returned from receive() are broadcast to this group.
""" """
raise NotImplementedError( raise NotImplementedError(f"{self.__class__.__name__} must implement group()")
f"{self.__class__.__name__} must implement group()"
)
def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None: def receive(self, params: BaseModel | None, msg: BaseModel) -> BaseModel | None:
""" """
@@ -191,9 +190,9 @@ class ReactChannel:
"type": "channel.message", "type": "channel.message",
"channel": self._registered_name, "channel": self._registered_name,
"params": self._params_dict, "params": self._params_dict,
"data": message.model_dump(mode='json'), "data": message.model_dump(mode="json"),
"message_type": message.__class__.__name__, "message_type": message.__class__.__name__,
} },
) )
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -215,7 +214,9 @@ class ReactChannel:
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
if not 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 return
# Build params model if defined # Build params model if defined
@@ -234,9 +235,9 @@ class ReactChannel:
"type": "channel.message", "type": "channel.message",
"channel": cls._registered_name, "channel": cls._registered_name,
"params": params, "params": params,
"data": message.model_dump(mode='json'), "data": message.model_dump(mode="json"),
"message_type": message.__class__.__name__, "message_type": message.__class__.__name__,
} },
) )
@@ -261,9 +262,9 @@ def register(channel_class: Type[ReactChannel], name: str) -> None:
channel_class._registered_name = name channel_class._registered_name = name
# Validate the channel class # 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()") 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()") raise ValueError(f"{channel_class.__name__} must implement group()")
_registry[name] = channel_class _registry[name] = channel_class
@@ -284,12 +285,13 @@ def get_registered_channels() -> dict[str, Type[ReactChannel]]:
# WebSocket Consumer # WebSocket Consumer
# ============================================================================= # =============================================================================
def get_websocket_application(): def get_websocket_application():
""" """
Get the WebSocket application for ASGI. Get the WebSocket application for ASGI.
Usage in asgi.py: Usage in asgi.py:
from djarea import channels from mizan import channels
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": get_asgi_application(), "http": get_asgi_application(),
@@ -309,9 +311,11 @@ def get_websocket_application():
from .connection import DjangoReactConsumer from .connection import DjangoReactConsumer
return AuthMiddlewareStack( return AuthMiddlewareStack(
URLRouter([ URLRouter(
path("ws/", DjangoReactConsumer.as_asgi()), [
]) path("ws/", DjangoReactConsumer.as_asgi()),
]
)
) )
@@ -319,15 +323,14 @@ def get_websocket_application():
# Schema Export (for TypeScript generation) # Schema Export (for TypeScript generation)
# ============================================================================= # =============================================================================
def get_channels_schema() -> dict: def get_channels_schema() -> dict:
""" """
Get schema for all registered channels (for TypeScript generation). Get schema for all registered channels (for TypeScript generation).
Returns a dict suitable for the frontend code generator. Returns a dict suitable for the frontend code generator.
""" """
schema = { schema = {"channels": {}}
"channels": {}
}
for name, channel_class in _registry.items(): for name, channel_class in _registry.items():
channel_schema = { channel_schema = {
@@ -338,16 +341,20 @@ def get_channels_schema() -> dict:
} }
# Extract Params schema # 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() channel_schema["params"] = channel_class.Params.model_json_schema()
# Extract ReactMessage schema # Extract ReactMessage schema
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage: if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
channel_schema["reactMessage"] = channel_class.ReactMessage.model_json_schema() channel_schema[
"reactMessage"
] = channel_class.ReactMessage.model_json_schema()
# Extract DjangoMessage schema # Extract DjangoMessage schema
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage: if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
channel_schema["djangoMessage"] = channel_class.DjangoMessage.model_json_schema() channel_schema[
"djangoMessage"
] = channel_class.DjangoMessage.model_json_schema()
schema["channels"][name] = channel_schema schema["channels"][name] = channel_schema
@@ -364,14 +371,19 @@ def _register_channel_schema_endpoint(
) -> None: ) -> None:
"""Register a dummy endpoint for schema generation (avoids closure issues).""" """Register a dummy endpoint for schema generation (avoids closure issues)."""
if input_cls is not None: if input_cls is not None:
def endpoint(request, data): def endpoint(request, data):
pass pass
endpoint.__annotations__ = {"data": input_cls} endpoint.__annotations__ = {"data": input_cls}
else: else:
def endpoint(request): def endpoint(request):
pass 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: def get_channels_openapi_schema() -> dict:
@@ -386,9 +398,9 @@ def get_channels_openapi_schema() -> dict:
# Create temporary Ninja API for schema generation only # Create temporary Ninja API for schema generation only
schema_api = NinjaAPI( schema_api = NinjaAPI(
title="Djarea Channels", title="mizan Channels",
version="1.0.0", version="1.0.0",
description="Auto-generated schema for djarea channels", description="Auto-generated schema for mizan channels",
docs_url=None, docs_url=None,
openapi_url=None, openapi_url=None,
) )
@@ -409,7 +421,7 @@ def get_channels_openapi_schema() -> dict:
} }
# Register Params type # 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" params_name = f"{pascal_name}Params"
schema_classes[params_name] = type(params_name, (channel_class.Params,), {}) schema_classes[params_name] = type(params_name, (channel_class.Params,), {})
channel_meta["hasParams"] = True channel_meta["hasParams"] = True
@@ -426,9 +438,11 @@ def get_channels_openapi_schema() -> dict:
) )
# Register ReactMessage type # 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" 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["hasReactMessage"] = True
channel_meta["reactMessageType"] = react_name channel_meta["reactMessageType"] = react_name
@@ -442,9 +456,11 @@ def get_channels_openapi_schema() -> dict:
) )
# Register DjangoMessage type # 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" 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["hasDjangoMessage"] = True
channel_meta["djangoMessageType"] = django_name channel_meta["djangoMessageType"] = django_name
@@ -464,7 +480,7 @@ def get_channels_openapi_schema() -> dict:
schema = schema_api.get_openapi_schema(path_prefix="") schema = schema_api.get_openapi_schema(path_prefix="")
# Add channel metadata extension # Add channel metadata extension
schema["x-djarea-channels"] = channel_metadata schema["x-mizan-channels"] = channel_metadata
return schema return schema

View File

@@ -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. 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._try_jwt_auth()
await self.accept() 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): async def _try_jwt_auth(self):
""" """
@@ -127,8 +129,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate JWT and create JWTUser (no DB query) # Validate JWT and create JWTUser (no DB query)
try: try:
from djarea.client.jwt import decode_token from mizan.client.jwt import decode_token
from djarea.jwt.tokens import JWTUser from mizan.jwt.tokens import JWTUser
payload = await sync_to_async(decode_token)(token, expected_type="access") payload = await sync_to_async(decode_token)(token, expected_type="access")
if payload is None: if payload is None:
@@ -166,9 +168,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
elif action == "rpc": elif action == "rpc":
await self._handle_rpc(content) await self._handle_rpc(content)
else: else:
await self.send_json({ await self.send_json(
"error": f"Unknown action: {action}", {
}) "error": f"Unknown action: {action}",
}
)
async def _handle_subscribe(self, content: dict): async def _handle_subscribe(self, content: dict):
"""Handle subscription request.""" """Handle subscription request."""
@@ -178,9 +182,11 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Get channel class # Get channel class
channel_class = get_channel(channel_name) channel_class = get_channel(channel_name)
if not channel_class: if not channel_class:
await self.send_json({ await self.send_json(
"error": f"Unknown channel: {channel_name}", {
}) "error": f"Unknown channel: {channel_name}",
}
)
return return
# Create subscription key # Create subscription key
@@ -189,11 +195,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Check if already subscribed # Check if already subscribed
if sub_key in self._subscriptions: if sub_key in self._subscriptions:
await self.send_json({ await self.send_json(
"error": f"Already subscribed to {channel_name}", {
"channel": channel_name, "error": f"Already subscribed to {channel_name}",
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
return return
# Create channel instance # Create channel instance
@@ -210,10 +218,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
try: try:
params_obj = channel_class.Params(**params_dict) params_obj = channel_class.Params(**params_dict)
except Exception as e: except Exception as e:
await self.send_json({ await self.send_json(
"error": f"Invalid params: {e}", {
"channel": channel_name, "error": f"Invalid params: {e}",
}) "channel": channel_name,
}
)
return return
# Check authorization # Check authorization
@@ -224,17 +234,21 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
authorized = instance.authorize() authorized = instance.authorize()
except Exception as e: except Exception as e:
logger.error(f"Authorization error for {channel_name}: {e}") logger.error(f"Authorization error for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": "Authorization failed", {
"channel": channel_name, "error": "Authorization failed",
}) "channel": channel_name,
}
)
return return
if not authorized: if not authorized:
await self.send_json({ await self.send_json(
"error": "Not authorized", {
"channel": channel_name, "error": "Not authorized",
}) "channel": channel_name,
}
)
return return
# Get group and join # Get group and join
@@ -246,10 +260,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
await instance._join_group(group_name) await instance._join_group(group_name)
except Exception as e: except Exception as e:
logger.error(f"Failed to join group for {channel_name}: {e}") logger.error(f"Failed to join group for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": f"Failed to subscribe: {e}", {
"channel": channel_name, "error": f"Failed to subscribe: {e}",
}) "channel": channel_name,
}
)
return return
# Store subscription # Store subscription
@@ -262,11 +278,13 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
logger.error(f"on_connect error for {channel_name}: {e}") logger.error(f"on_connect error for {channel_name}: {e}")
# Confirm subscription # Confirm subscription
await self.send_json({ await self.send_json(
"subscribed": True, {
"channel": channel_name, "subscribed": True,
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
logger.debug(f"Subscribed to {channel_name} with 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: except Exception as e:
logger.error(f"Error during unsubscribe: {e}") logger.error(f"Error during unsubscribe: {e}")
await self.send_json({ await self.send_json(
"unsubscribed": True, {
"channel": channel_name, "unsubscribed": True,
"params": params_dict, "channel": channel_name,
}) "params": params_dict,
}
)
logger.debug(f"Unsubscribed from {channel_name}") logger.debug(f"Unsubscribed from {channel_name}")
@@ -305,30 +325,36 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
instance = self._subscriptions.get(sub_key) instance = self._subscriptions.get(sub_key)
if not instance: if not instance:
await self.send_json({ await self.send_json(
"error": f"Not subscribed to {channel_name}", {
"channel": channel_name, "error": f"Not subscribed to {channel_name}",
}) "channel": channel_name,
}
)
return return
channel_class = instance.__class__ channel_class = instance.__class__
# Check if channel accepts messages # Check if channel accepts messages
if not channel_class.ReactMessage: if not channel_class.ReactMessage:
await self.send_json({ await self.send_json(
"error": f"Channel {channel_name} does not accept messages", {
"channel": channel_name, "error": f"Channel {channel_name} does not accept messages",
}) "channel": channel_name,
}
)
return return
# Parse message # Parse message
try: try:
msg = channel_class.ReactMessage(**data) msg = channel_class.ReactMessage(**data)
except Exception as e: except Exception as e:
await self.send_json({ await self.send_json(
"error": f"Invalid message: {e}", {
"channel": channel_name, "error": f"Invalid message: {e}",
}) "channel": channel_name,
}
)
return return
# Parse params # Parse params
@@ -351,10 +377,12 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
except Exception as e: except Exception as e:
logger.error(f"Error handling message for {channel_name}: {e}") logger.error(f"Error handling message for {channel_name}: {e}")
await self.send_json({ await self.send_json(
"error": f"Message handling failed: {e}", {
"channel": channel_name, "error": f"Message handling failed: {e}",
}) "channel": channel_name,
}
)
async def _handle_rpc(self, content: dict): async def _handle_rpc(self, content: dict):
""" """
@@ -371,8 +399,8 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
- Function must be explicitly registered (no arbitrary code execution) - Function must be explicitly registered (no arbitrary code execution)
- User context from WebSocket session is passed to function - User context from WebSocket session is passed to function
""" """
from djarea.client.executor import execute_function, FunctionError from mizan.client.executor import execute_function, FunctionError
from djarea.setup.registry import get_function from mizan.setup.registry import get_function
request_id = content.get("id") request_id = content.get("id")
fn_name = content.get("fn") fn_name = content.get("fn")
@@ -380,50 +408,60 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Validate request structure # Validate request structure
if not request_id: if not request_id:
await self.send_json({ await self.send_json(
"error": "RPC request missing 'id' field", {
}) "error": "RPC request missing 'id' field",
}
)
return return
if not fn_name: if not fn_name:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "BAD_REQUEST", "error": {
"message": "Missing 'fn' field", "code": "BAD_REQUEST",
}, "message": "Missing 'fn' field",
}) },
}
)
return return
# Check if function exists and has websocket=True # Check if function exists and has websocket=True
fn_class = get_function(fn_name) fn_class = get_function(fn_name)
if fn_class is None: if fn_class is None:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "NOT_FOUND", "error": {
"message": f"Function '{fn_name}' not found", "code": "NOT_FOUND",
}, "message": f"Function '{fn_name}' not found",
}) },
}
)
return return
# Only allow functions explicitly marked with websocket=True # Only allow functions explicitly marked with websocket=True
fn_meta = getattr(fn_class, "_meta", {}) fn_meta = getattr(fn_class, "_meta", {})
if not fn_meta.get("websocket"): if not fn_meta.get("websocket"):
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": "FORBIDDEN", "error": {
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.", "code": "FORBIDDEN",
}, "message": "This function is HTTP-only. Use POST /api/mizan/call/ instead.",
}) },
}
)
return return
# Create request adapter from WebSocket scope # 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) # Execute function (Pydantic validation happens inside execute_function)
# This is sync, so we need to run it in a thread pool # This is sync, so we need to run it in a thread pool
@@ -435,21 +473,25 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
# Send response # Send response
if isinstance(result, FunctionError): if isinstance(result, FunctionError):
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": False, "id": request_id,
"error": { "ok": False,
"code": result.code.value, "error": {
"message": result.message, "code": result.code.value,
**({"details": result.details} if result.details else {}), "message": result.message,
}, **({"details": result.details} if result.details else {}),
}) },
}
)
else: else:
await self.send_json({ await self.send_json(
"id": request_id, {
"ok": True, "id": request_id,
"data": result.data, "ok": True,
}) "data": result.data,
}
)
async def channel_message(self, event: dict): async def channel_message(self, event: dict):
""" """
@@ -458,12 +500,14 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Called when channel_layer.group_send() is used. Called when channel_layer.group_send() is used.
Includes channel name and params so the client can route the message. Includes channel name and params so the client can route the message.
""" """
await self.send_json({ await self.send_json(
"channel": event.get("channel"), {
"params": event.get("params", {}), "channel": event.get("channel"),
"type": event.get("message_type", "message"), "params": event.get("params", {}),
"data": event.get("data", {}), "type": event.get("message_type", "message"),
}) "data": event.get("data", {}),
}
)
async def push_message(self, event: dict): async def push_message(self, event: dict):
""" """
@@ -475,8 +519,10 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
Protocol: Protocol:
Server sends: {"type": "push", "topic": "room:42", "data": {...}} Server sends: {"type": "push", "topic": "room:42", "data": {...}}
""" """
await self.send_json({ await self.send_json(
"type": "push", {
"topic": event.get("topic"), "type": "push",
"data": event.get("data", {}), "topic": event.get("topic"),
}) "data": event.get("data", {}),
}
)

View File

@@ -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. Simple API for pushing data to subscribed WebSocket connections.
Usage: Usage:
# In a server function - push to all subscribers # 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": {...}}) push("room:42", {"type": "new_message", "data": {...}})
# Subscribe a connection to a topic (call during context fetch) # Subscribe a connection to a topic (call during context fetch)
from djarea.push import subscribe from mizan.push import subscribe
subscribe(request, "room:42") subscribe(request, "room:42")
""" """
@@ -29,6 +29,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
"""Get channel layer, returning None if channels is not installed.""" """Get channel layer, returning None if channels is not installed."""
try: try:
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
return get_channel_layer() return get_channel_layer()
except ImportError: except ImportError:
return None return None
@@ -37,6 +38,7 @@ def _get_channel_layer() -> "BaseChannelLayer | None":
def _async_to_sync(coro): def _async_to_sync(coro):
"""Wrapper for async_to_sync that handles missing channels.""" """Wrapper for async_to_sync that handles missing channels."""
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
return async_to_sync(coro) return async_to_sync(coro)
@@ -108,6 +110,7 @@ def push(topic: str, data: dict | BaseModel) -> None:
channel_layer = _get_channel_layer() channel_layer = _get_channel_layer()
if not channel_layer: if not channel_layer:
import logging import logging
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"No channel layer configured, cannot push to topic '%s'", topic "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 "type": "push.message", # Maps to push_message handler in consumer
"topic": topic, "topic": topic,
"data": data, "data": data,
} },
) )
@@ -146,5 +149,5 @@ async def push_async(topic: str, data: dict | BaseModel) -> None:
"type": "push.message", "type": "push.message",
"topic": topic, "topic": topic,
"data": data, "data": data,
} },
) )

View File

@@ -1,5 +1,5 @@
""" """
djarea.client - Server function implementation. mizan.client - Server function implementation.
This subpackage contains everything needed to make server functions work: This subpackage contains everything needed to make server functions work:
- The @client decorator - The @client decorator
@@ -8,7 +8,7 @@ This subpackage contains everything needed to make server functions work:
- JWT authentication (integral to server functions) - JWT authentication (integral to server functions)
Usage: Usage:
from djarea.client import client, ServerFunction, compose from mizan.client import client, ServerFunction, compose
""" """
from .function import ( from .function import (

View File

@@ -1,5 +1,5 @@
""" """
Djarea Function Executor mizan Function Executor
Handles execution of server functions. Handles execution of server functions.
This is the core of the "Server Functions" feature - callable from React 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 django.views.decorators.csrf import csrf_protect
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from djarea.setup.registry import get_function from mizan.setup.registry import get_function
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -134,23 +134,23 @@ def _check_auth_requirement(
) )
# Check authentication (required for all string-based auth) # Check authentication (required for all string-based auth)
if not getattr(user, 'is_authenticated', False): if not getattr(user, "is_authenticated", False):
return FunctionError( return FunctionError(
code=ErrorCode.UNAUTHORIZED, code=ErrorCode.UNAUTHORIZED,
message="Authentication required", message="Authentication required",
) )
# Check staff requirement # Check staff requirement
if auth_requirement == 'staff': if auth_requirement == "staff":
if not getattr(user, 'is_staff', False): if not getattr(user, "is_staff", False):
return FunctionError( return FunctionError(
code=ErrorCode.FORBIDDEN, code=ErrorCode.FORBIDDEN,
message="Staff access required", message="Staff access required",
) )
# Check superuser requirement # Check superuser requirement
elif auth_requirement == 'superuser': elif auth_requirement == "superuser":
if not getattr(user, 'is_superuser', False): if not getattr(user, "is_superuser", False):
return FunctionError( return FunctionError(
code=ErrorCode.FORBIDDEN, code=ErrorCode.FORBIDDEN,
message="Superuser access required", message="Superuser access required",
@@ -224,7 +224,8 @@ def execute_function(
if not isinstance(input_data, dict): if not isinstance(input_data, dict):
return FunctionError( return FunctionError(
code=ErrorCode.BAD_REQUEST, 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) validated_input = input_cls(**input_data)
elif has_input: elif has_input:
@@ -280,7 +281,9 @@ def execute_function(
code=ErrorCode.INTERNAL_ERROR, code=ErrorCode.INTERNAL_ERROR,
message="An internal error occurred", message="An internal error occurred",
# Don't expose internal details in production # 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) # Serialize output (handle None for Optional return types)
@@ -313,8 +316,8 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
return False return False
try: try:
from djarea.client.jwt import decode_token from mizan.client.jwt import decode_token
from djarea.jwt.tokens import JWTUser from mizan.jwt.tokens import JWTUser
payload = decode_token(token, expected_type="access") payload = decode_token(token, expected_type="access")
if payload is None: if payload is None:
@@ -322,7 +325,7 @@ def _try_jwt_auth(request: HttpRequest) -> bool:
# Create JWTUser from token claims - NO DATABASE QUERY # Create JWTUser from token claims - NO DATABASE QUERY
request.user = JWTUser(payload) request.user = JWTUser(payload)
request._djarea_jwt_authenticated = True request._mizan_jwt_authenticated = True
return True return True
except Exception: except Exception:
return False return False
@@ -379,7 +382,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
- JWT: Authorization: Bearer <token> (stateless, no CSRF needed) - JWT: Authorization: Bearer <token> (stateless, no CSRF needed)
- Session: Cookie-based with X-CSRFToken header (CSRF required) - Session: Cookie-based with X-CSRFToken header (CSRF required)
Endpoint: POST /api/djarea/call/ Endpoint: POST /api/mizan/call/
Request body (JSON): 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"} 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 # Attach parsed form data and files to request for form functions
request._djarea_form_data = input_data request._mizan_form_data = input_data
request._djarea_form_files = request.FILES request._mizan_form_files = request.FILES
else: else:
# JSON body - standard RPC # JSON body - standard RPC

View File

@@ -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. Server functions are the core primitive. Everything else builds on them.
@@ -21,14 +21,25 @@ from __future__ import annotations
import inspect import inspect
from abc import ABC, abstractmethod 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 django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
# Valid context modes: 'global', 'local', or False (not a context) # 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) TInput = TypeVar("TInput", bound=BaseModel)
@@ -167,7 +178,7 @@ class _FunctionWrapper(ServerFunction):
# Valid string values for auth parameter # Valid string values for auth parameter
_VALID_AUTH_STRINGS = frozenset({'required', 'staff', 'superuser'}) _VALID_AUTH_STRINGS = frozenset({"required", "staff", "superuser"})
def client( def client(
@@ -194,7 +205,7 @@ def client(
real-time features (chat, gaming, live updates) that benefit real-time features (chat, gaming, live updates) that benefit
from lower latency. 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. flows require full HTTP request semantics.
auth: Authentication requirement. auth: Authentication requirement.
@@ -234,7 +245,7 @@ def client(
A ServerFunction class that wraps the function A ServerFunction class that wraps the function
""" """
# Validate context parameter # Validate context parameter
if context not in (False, 'global', 'local'): if context not in (False, "global", "local"):
raise ValueError( raise ValueError(
f"Invalid context value '{context}'. " f"Invalid context value '{context}'. "
f"Must be False, 'global', or 'local'." f"Must be False, 'global', or 'local'."
@@ -249,11 +260,15 @@ def client(
) )
def decorator(fn: Callable) -> type[ServerFunction]: 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(...) # Support both @client and @client(...)
if fn is not None: 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 return decorator
@@ -301,9 +316,7 @@ def _create_server_function(
# Get output type from return annotation # Get output type from return annotation
output_type = hints.get("return") output_type = hints.get("return")
if output_type is None: if output_type is None:
raise TypeError( raise TypeError(f"Server function '{name}' must have a return type annotation")
f"Server function '{name}' must have a return type annotation"
)
# Support primitive return types by wrapping in a model with 'result' field # Support primitive return types by wrapping in a model with 'result' field
# Also handle Optional[X] / X | None by extracting the non-None type # Also handle Optional[X] / X | None by extracting the non-None type
@@ -319,7 +332,11 @@ def _create_server_function(
args = get_args(t) args = get_args(t)
# Check if any non-None arg is a BaseModel # Check if any non-None arg is a BaseModel
for arg in args: 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 True
return False return False
@@ -365,7 +382,7 @@ def _create_server_function(
# Auth requirement # Auth requirement
if auth is not None: if auth is not None:
if auth is True: if auth is True:
meta["auth"] = 'required' meta["auth"] = "required"
elif callable(auth): elif callable(auth):
meta["auth"] = auth meta["auth"] = auth
else: else:
@@ -374,7 +391,7 @@ def _create_server_function(
if meta: if meta:
FunctionWrapper._meta = {**FunctionWrapper._meta, **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. # This allows the decorator to be used without import-time side effects.
return FunctionWrapper return FunctionWrapper
@@ -434,7 +451,7 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
return [item] return [item]
elif isinstance(item, ComposedContext): elif isinstance(item, ComposedContext):
return item._leaves.copy() return item._leaves.copy()
elif hasattr(item, '_leaves'): elif hasattr(item, "_leaves"):
# Duck typing for composed contexts # Duck typing for composed contexts
return item._leaves.copy() return item._leaves.copy()
else: else:
@@ -443,11 +460,11 @@ def _get_leaves(item) -> list[type[ServerFunction]]:
def _is_context_enabled(item) -> bool: def _is_context_enabled(item) -> bool:
"""Check if an item is a context-enabled function or composition.""" """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 return True
if isinstance(item, type) and issubclass(item, ServerFunction): if isinstance(item, type) and issubclass(item, ServerFunction):
meta = getattr(item, '_meta', {}) meta = getattr(item, "_meta", {})
return meta.get('context') in ('global', 'local') return meta.get("context") in ("global", "local")
return False return False
@@ -498,15 +515,18 @@ def compose(
Returns: Returns:
A ComposedContext that can be used in other compositions. A ComposedContext that can be used in other compositions.
""" """
def decorator(fn: Callable) -> ComposedContext: def decorator(fn: Callable) -> ComposedContext:
from djarea.setup.registry import register_compose from mizan.setup.registry import register_compose
name = fn.__name__ name = fn.__name__
# Validate: all children must be context-enabled # Validate: all children must be context-enabled
for i, child in enumerate(children): for i, child in enumerate(children):
if not _is_context_enabled(child): 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( raise ValueError(
f"@compose argument {i} ({child_name}) is not context-enabled. " f"@compose argument {i} ({child_name}) is not context-enabled. "
f"All children must have @client(context='global'|'local') or be @compose." 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 # Validate transport consistency when on_server=True
if on_server: 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: if websocket:
# All must have websocket=True # All must have websocket=True
if not all(has_websocket): 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( raise ValueError(
f"@compose({name}, on_server=True, websocket=True) requires all children " f"@compose({name}, on_server=True, websocket=True) requires all children "
f"to have websocket=True. These are HTTP-only: {non_ws}" f"to have websocket=True. These are HTTP-only: {non_ws}"
@@ -542,7 +566,9 @@ def compose(
else: else:
# All must be HTTP-only # All must be HTTP-only
if any(has_websocket): 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( raise ValueError(
f"@compose({name}, on_server=True, websocket=False) requires all children " f"@compose({name}, on_server=True, websocket=False) requires all children "
f"to be HTTP-only. These have websocket=True: {ws_enabled}" f"to be HTTP-only. These have websocket=True: {ws_enabled}"
@@ -628,7 +654,7 @@ def create_form_functions(
Or use the helper: Or use the helper:
register_form(ContactForm, 'contact', submit_handler=...) 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 # Schema function - returns field definitions
class FormSchema(ServerFunction): class FormSchema(ServerFunction):
@@ -644,7 +670,9 @@ def create_form_functions(
required=field.required, required=field.required,
label=field.label or field.name, label=field.label or field.name,
help_text=field.help_text or None, 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, initial=field.initial,
) )
for field in schema.fields for field in schema.fields

View File

@@ -1,5 +1,5 @@
""" """
djarea.client.jwt - JWT authentication for server functions. mizan.client.jwt - JWT authentication for server functions.
Provides: Provides:
- Server functions for obtaining/refreshing JWT tokens - Server functions for obtaining/refreshing JWT tokens
@@ -9,12 +9,12 @@ Server Functions:
- jwt_obtain: Convert authenticated session to JWT tokens - jwt_obtain: Convert authenticated session to JWT tokens
- jwt_refresh: Refresh tokens using a refresh token - jwt_refresh: Refresh tokens using a refresh token
Note: This module is purpose-built for Djarea server functions. Note: This module is purpose-built for mizan server functions.
For Django Ninja API authentication, use djarea.jwt.security directly. For Django Ninja API authentication, use mizan.jwt.security directly.
""" """
# Token utilities (re-exports from django_jwt_session) # Token utilities (re-exports from django_jwt_session)
from djarea.jwt.tokens import ( from mizan.jwt.tokens import (
create_token_pair, create_token_pair,
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
@@ -26,7 +26,7 @@ from djarea.jwt.tokens import (
) )
# Settings # Settings
from djarea.jwt.settings import get_settings, JWTSettings from mizan.jwt.settings import get_settings, JWTSettings
__all__ = [ __all__ = [
# Token utilities # Token utilities

View File

@@ -1,5 +1,5 @@
""" """
Djarea OpenAPI Schema Generator mizan OpenAPI Schema Generator
Generates OpenAPI 3.0 compatible schema from registered server functions. Generates OpenAPI 3.0 compatible schema from registered server functions.
Uses Django Ninja's battle-tested schema generation for robust Pydantic→OpenAPI conversion. 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. HTTP endpoint has been removed to prevent function enumeration.
Usage: Usage:
python manage.py export_djarea_schema python manage.py export_mizan_schema
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,12 +21,12 @@ import re
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
# Lazy imports to avoid Django settings access at module load time # 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: if TYPE_CHECKING:
from django import forms from django import forms
from ninja import NinjaAPI 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"] __all__ = ["get_schema", "generate_openapi_schema", "generate_openapi_json"]
@@ -167,21 +167,26 @@ def _register_schema_endpoint(
and exec() security concerns. and exec() security concerns.
""" """
if input_cls is not None: if input_cls is not None:
def endpoint(request, data): def endpoint(request, data):
pass pass
# Set annotations directly to the actual type objects (not strings) # Set annotations directly to the actual type objects (not strings)
endpoint.__annotations__ = {"data": input_cls} endpoint.__annotations__ = {"data": input_cls}
else: else:
def endpoint(request): def endpoint(request):
pass pass
# Register with Ninja # 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]: 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 Uses Django Ninja's schema generation internally to ensure proper
PydanticOpenAPI conversion (handling $refs, nested types, etc.). PydanticOpenAPI 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 # This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
# battle-tested Pydantic→OpenAPI conversion # battle-tested Pydantic→OpenAPI conversion
schema_api = NinjaAPI( schema_api = NinjaAPI(
title="Djarea Server Functions", title="mizan Server Functions",
version="1.0.0", 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 docs_url=None, # No docs endpoint
openapi_url=None, # No openapi 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 # Store them in schema_classes so they persist beyond loop scope
# Uses create_model to avoid metaclass conflicts with custom base classes # Uses create_model to avoid metaclass conflicts with custom base classes
if has_input: if has_input:
schema_classes[input_type_name] = create_model(input_type_name, __base__=input_cls) schema_classes[input_type_name] = create_model(
schema_classes[output_type_name] = create_model(output_type_name, __base__=output_cls) 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 endpoint using helper to avoid closure capture issues
_register_schema_endpoint( _register_schema_endpoint(
api=schema_api, api=schema_api,
path=f"/djarea/{name}", path=f"/mizan/{name}",
operation_id=camel_name, operation_id=camel_name,
summary=fn_class.__doc__ or f"Call {name}", summary=fn_class.__doc__ or f"Call {name}",
input_cls=schema_classes.get(input_type_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="") schema = schema_api.get_openapi_schema(path_prefix="")
# Add custom extension with function metadata for provider generation # 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: for fn_meta in function_metadata:
path = f"/djarea/{fn_meta['name']}" path = f"/mizan/{fn_meta['name']}"
if path in schema.get("paths", {}): if path in schema.get("paths", {}):
schema["paths"][path]["post"]["x-djarea"] = { schema["paths"][path]["post"]["x-mizan"] = {
"transport": fn_meta["transport"], "transport": fn_meta["transport"],
"isContext": fn_meta["isContext"], "isContext": fn_meta["isContext"],
} }

View File

@@ -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.) preserving full Django Form functionality (validation, widgets, ModelChoiceField, etc.)
while exposing them through the unified server function API. while exposing them through the unified server function API.
Usage: Usage:
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
submit_label="Send", submit_label="Send",
@@ -98,7 +98,7 @@ def _create_form_input_schema(
form = form_class() form = form_class()
except TypeError: except TypeError:
# Form requires extra args (like request) - use form_class.base_fields instead # 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: else:
fields_dict = form.fields fields_dict = form.fields
@@ -125,9 +125,9 @@ def _create_form_input_schema(
return model 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, This Pydantic model provides type-safe configuration with full LSP support,
and serializes to JSON for the frontend schema. and serializes to JSON for the frontend schema.
@@ -167,14 +167,14 @@ class DjareaFormMeta(BaseModel):
enable_formset: bool = False 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): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
) )
@@ -197,10 +197,10 @@ class DjareaFormMixin:
""" """
# Configuration - subclasses must define this # Configuration - subclasses must define this
djarea: ClassVar[DjareaFormMeta] mizan: ClassVar[mizanFormMeta]
# Track registered forms to avoid duplicate registration # Track registered forms to avoid duplicate registration
_djarea_registered: ClassVar[bool] = False _mizan_registered: ClassVar[bool] = False
@classmethod @classmethod
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]: def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
@@ -236,9 +236,7 @@ class DjareaFormMixin:
return result return result
return None return None
def on_submit_failure( def on_submit_failure(self, request: HttpRequest, errors: "FormValidation") -> None:
self, request: HttpRequest, errors: "FormValidation"
) -> None:
""" """
Called after form validation fails. Called after form validation fails.
@@ -250,23 +248,23 @@ class DjareaFormMixin:
"""Auto-register when a concrete form class is defined.""" """Auto-register when a concrete form class is defined."""
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
# Only register concrete forms with djarea config defined # Only register concrete forms with mizan config defined
if _is_concrete_djarea_form(cls): if _is_concrete_mizan_form(cls):
_register_form_as_server_functions(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: 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 2. It inherits from Django's BaseForm
3. It hasn't been registered yet (for this class definition) 3. It hasn't been registered yet (for this class definition)
""" """
# Must have djarea config (check cls.__dict__ to avoid inheriting) # Must have mizan config (check cls.__dict__ to avoid inheriting)
djarea_config = cls.__dict__.get("djarea") mizan_config = cls.__dict__.get("mizan")
if not isinstance(djarea_config, DjareaFormMeta): if not isinstance(mizan_config, mizanFormMeta):
return False return False
# Must be a Django form # Must be a Django form
@@ -274,7 +272,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
return False return False
# Check if already registered (handle re-imports gracefully) # 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 False
return True return True
@@ -282,7 +280,7 @@ def _is_concrete_djarea_form(cls: type) -> bool:
def _register_form_as_server_functions(form_class: type) -> None: 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: Creates and registers:
- {name}.schema - Returns form field definitions - {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 .schemas import FormSchema, FormSubmitFail, FormSubmitPass, FormValidation
from .schema_utils import build_form_schema from .schema_utils import build_form_schema
from .validation_utils import validate_form_instance from .validation_utils import validate_form_instance
from djarea.setup.registry import register from mizan.setup.registry import register
from djarea.client.function import ServerFunction from mizan.client.function import ServerFunction
config: DjareaFormMeta = form_class.djarea config: mizanFormMeta = form_class.mizan
form_name = config.name form_name = config.name
# Mark as registered # Mark as registered
form_class._djarea_registered = True form_class._mizan_registered = True
# Generate PascalCase name for schemas (e.g., "contact" -> "Contact") # 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 # NOTE: We cannot create FormDataSchema here because form fields aren't
# populated yet during __init_subclass__. We use lazy creation instead. # 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 {}, data=input.data if input else {},
**init_kwargs, **init_kwargs,
) )
# Override with DjareaFormMeta values # Override with mizanFormMeta values
if config.title is not None: if config.title is not None:
schema.title = config.title schema.title = config.title
if config.subtitle is not None: if config.subtitle is not None:
@@ -424,9 +425,9 @@ def _register_form_as_server_functions(form_class: type) -> None:
request = self.request request = self.request
# Check if we have multipart data from executor # Check if we have multipart data from executor
if hasattr(request, "_djarea_form_data"): if hasattr(request, "_mizan_form_data"):
data = request._djarea_form_data data = request._mizan_form_data
files = request._djarea_form_files files = request._mizan_form_files
elif input is not None: elif input is not None:
# JSON input - already a dict # JSON input - already a dict
data = input if isinstance(input, dict) else input.model_dump() 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.""" """Register formset server functions for a form."""
from django.forms import formset_factory 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 .schema_utils import build_form_schema
from .validation_utils import build_formset_validation from .validation_utils import build_formset_validation
from .formset_utils import forms_to_formset_post_data from .formset_utils import forms_to_formset_post_data
from djarea.setup.registry import register from mizan.setup.registry import register
from djarea.client.function import ServerFunction from mizan.client.function import ServerFunction
formset_class = formset_factory(form_class) formset_class = formset_factory(form_class)
# Generate PascalCase name for schemas # 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 # NOTE: We cannot create typed schemas here because form fields aren't
# populated yet during __init_subclass__. We use generic dict inputs. # populated yet during __init_subclass__. We use generic dict inputs.
@@ -506,7 +515,7 @@ def _register_formset_functions(
"form": True, "form": True,
"form_name": form_name, "form_name": form_name,
"form_role": "formset_schema", "form_role": "formset_schema",
} }
def call(self, input) -> FormsetSchema: def call(self, input) -> FormsetSchema:
init_kwargs = form_class.get_init_kwargs(self.request) 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) init_kwargs = form_class.get_init_kwargs(request)
# Handle multipart vs JSON # Handle multipart vs JSON
if hasattr(request, "_djarea_form_data"): if hasattr(request, "_mizan_form_data"):
post_data = request._djarea_form_data post_data = request._mizan_form_data
files = request._djarea_form_files files = request._mizan_form_files
elif input and hasattr(input, 'forms'): elif input and hasattr(input, "forms"):
# Input.forms is already a list of dicts # Input.forms is already a list of dicts
forms_data = input.forms forms_data = input.forms
post_data = forms_to_formset_post_data(forms_data) post_data = forms_to_formset_post_data(forms_data)

View File

@@ -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: Provides:
- Auth contexts (auth_status, user) - required by frontend allauth module - Auth contexts (auth_status, user) - required by frontend allauth module
@@ -11,8 +11,8 @@ Usage:
# In your app's apps.py # In your app's apps.py
class MyAppConfig(AppConfig): class MyAppConfig(AppConfig):
def ready(self): def ready(self):
import djarea.allauth.forms # noqa - registers forms import mizan.allauth.forms # noqa - registers forms
import djarea.allauth.contexts # noqa - registers contexts import mizan.allauth.contexts # noqa - registers contexts
""" """
from .contexts import auth_status, user, AuthStatusOutput, UserOutput from .contexts import auth_status, user, AuthStatusOutput, UserOutput

View File

@@ -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. These are the core auth primitives that the frontend allauth module depends on.
Separated into two concerns: Separated into two concerns:
@@ -13,7 +13,7 @@ Both are registered as global contexts for SSR hydration.
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel 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): class AuthStatusOutput(BaseModel):
"""Authentication status and permission guards.""" """Authentication status and permission guards."""
is_authenticated: bool is_authenticated: bool
user_id: int | None = None user_id: int | None = None
is_staff: bool = False is_staff: bool = False
is_superuser: bool = False is_superuser: bool = False
@client(context='global') @client(context="global")
def auth_status(request: HttpRequest) -> AuthStatusOutput: def auth_status(request: HttpRequest) -> AuthStatusOutput:
""" """
Auth status context - provides authentication state and guards. Auth status context - provides authentication state and guards.
@@ -62,13 +63,14 @@ def auth_status(request: HttpRequest) -> AuthStatusOutput:
class UserOutput(BaseModel): class UserOutput(BaseModel):
"""Full user profile data.""" """Full user profile data."""
id: int id: int
email: str email: str
first_name: str = "" first_name: str = ""
last_name: str = "" last_name: str = ""
@client(context='global') @client(context="global")
def user(request: HttpRequest) -> UserOutput | None: def user(request: HttpRequest) -> UserOutput | None:
""" """
User profile context - provides full user data. User profile context - provides full user data.
@@ -90,17 +92,18 @@ def user(request: HttpRequest) -> UserOutput | None:
return None return None
# Check if we have full user data or just JWT claims # 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) # Full User object (session auth)
return UserOutput( return UserOutput(
id=req_user.id, id=req_user.id,
email=req_user.email, email=req_user.email,
first_name=getattr(req_user, 'first_name', '') or '', first_name=getattr(req_user, "first_name", "") or "",
last_name=getattr(req_user, 'last_name', '') or '', last_name=getattr(req_user, "last_name", "") or "",
) )
# JWTUser - need to fetch from DB # JWTUser - need to fetch from DB
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
try: try:
@@ -108,8 +111,8 @@ def user(request: HttpRequest) -> UserOutput | None:
return UserOutput( return UserOutput(
id=db_user.id, id=db_user.id,
email=db_user.email, email=db_user.email,
first_name=db_user.first_name or '', first_name=db_user.first_name or "",
last_name=db_user.last_name or '', last_name=db_user.last_name or "",
) )
except User.DoesNotExist: except User.DoesNotExist:
return None return None

View File

@@ -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. typed server functions for the React frontend.
Each form becomes three server functions: 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): class MyAppConfig(AppConfig):
def ready(self): def ready(self):
import djarea.allauth.forms # noqa import mizan.allauth.forms # noqa
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any
from django.http import HttpRequest from django.http import HttpRequest
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
# Account forms # Account forms
from allauth.account.forms import ( from allauth.account.forms import (
@@ -41,6 +41,7 @@ from allauth.account.forms import (
# Password reauthentication form - conditionally import # Password reauthentication form - conditionally import
try: try:
from allauth.account.forms import ReauthenticateForm from allauth.account.forms import ReauthenticateForm
HAS_REAUTH = True HAS_REAUTH = True
except ImportError: except ImportError:
HAS_REAUTH = False HAS_REAUTH = False
@@ -51,6 +52,7 @@ try:
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
HAS_MFA = True HAS_MFA = True
except ImportError: except ImportError:
HAS_MFA = False HAS_MFA = False
@@ -58,22 +60,24 @@ except ImportError:
# WebAuthn forms (if available) # WebAuthn forms (if available)
try: try:
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
HAS_WEBAUTHN = True HAS_WEBAUTHN = True
except ImportError: except ImportError:
HAS_WEBAUTHN = False HAS_WEBAUTHN = False
if TYPE_CHECKING: if TYPE_CHECKING:
from djarea.forms.schemas import FormValidation from mizan.forms.schemas import FormValidation
# ============================================================================= # =============================================================================
# Account Forms # Account Forms
# ============================================================================= # =============================================================================
class DjareaLoginForm(LoginForm, DjareaFormMixin):
class mizanLoginForm(LoginForm, mizanFormMixin):
"""Sign in with email and password.""" """Sign in with email and password."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="login", name="login",
title="Sign In", title="Sign In",
subtitle="Welcome back. Enter your credentials to continue.", subtitle="Welcome back. Enter your credentials to continue.",
@@ -90,10 +94,10 @@ class DjareaLoginForm(LoginForm, DjareaFormMixin):
return None return None
class DjareaSignupForm(SignupForm, DjareaFormMixin): class mizanSignupForm(SignupForm, mizanFormMixin):
"""Create a new account.""" """Create a new account."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="signup", name="signup",
title="Create Account", title="Create Account",
subtitle="Enter your details to get started.", subtitle="Enter your details to get started.",
@@ -109,10 +113,10 @@ class DjareaSignupForm(SignupForm, DjareaFormMixin):
return None return None
class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin): class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
"""Add another email address to your account.""" """Add another email address to your account."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="add_email", name="add_email",
title="Add Email Address", title="Add Email Address",
subtitle="Add another email address to your account.", subtitle="Add another email address to your account.",
@@ -128,10 +132,10 @@ class DjareaAddEmailForm(AddEmailForm, DjareaFormMixin):
return None return None
class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin): class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
"""Change your account password.""" """Change your account password."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="change_password", name="change_password",
title="Change Password", title="Change Password",
subtitle="Update your password to keep your account secure.", subtitle="Update your password to keep your account secure.",
@@ -147,10 +151,10 @@ class DjareaChangePasswordForm(ChangePasswordForm, DjareaFormMixin):
return None return None
class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin): class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
"""Set a password for accounts created via social login.""" """Set a password for accounts created via social login."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="set_password", name="set_password",
title="Set Password", title="Set Password",
subtitle="Create a password for your account.", subtitle="Create a password for your account.",
@@ -166,10 +170,10 @@ class DjareaSetPasswordForm(SetPasswordForm, DjareaFormMixin):
return None return None
class DjareaResetPasswordForm(ResetPasswordForm, DjareaFormMixin): class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
"""Request a password reset email.""" """Request a password reset email."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="reset_password", name="reset_password",
title="Reset Password", title="Reset Password",
subtitle="Enter your email address and we'll send you a link to reset your 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 return None
class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin): class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
"""Set a new password using a reset key.""" """Set a new password using a reset key."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="reset_password_from_key", name="reset_password_from_key",
title="Set New Password", title="Set New Password",
subtitle="Enter your new password below.", subtitle="Enter your new password below.",
@@ -204,10 +208,10 @@ class DjareaResetPasswordKeyForm(ResetPasswordKeyForm, DjareaFormMixin):
return None return None
class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin): class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
"""Request a login code via email.""" """Request a login code via email."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="request_login_code", name="request_login_code",
title="Sign In with Code", title="Sign In with Code",
subtitle="Enter your email address and we'll send you a login code.", subtitle="Enter your email address and we'll send you a login code.",
@@ -223,10 +227,10 @@ class DjareaRequestLoginCodeForm(RequestLoginCodeForm, DjareaFormMixin):
return None return None
class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin): class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
"""Confirm a login code.""" """Confirm a login code."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="confirm_login_code", name="confirm_login_code",
title="Enter Code", title="Enter Code",
subtitle="Enter the code we sent to your email.", subtitle="Enter the code we sent to your email.",
@@ -242,10 +246,10 @@ class DjareaConfirmLoginCodeForm(ConfirmLoginCodeForm, DjareaFormMixin):
return None return None
class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin): class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
"""Verify an email with a token.""" """Verify an email with a token."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="user_token", name="user_token",
title="Verify Email", title="Verify Email",
subtitle="Enter the verification code from your email.", subtitle="Enter the verification code from your email.",
@@ -263,10 +267,11 @@ class DjareaUserTokenForm(UserTokenForm, DjareaFormMixin):
# Password reauthentication - conditionally define # Password reauthentication - conditionally define
if HAS_REAUTH: if HAS_REAUTH:
class DjareaReauthenticateForm(ReauthenticateForm, DjareaFormMixin):
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
"""Re-authenticate with password for sensitive actions.""" """Re-authenticate with password for sensitive actions."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="reauthenticate", name="reauthenticate",
title="Confirm Your Identity", title="Confirm Your Identity",
subtitle="Please enter your password to continue.", subtitle="Please enter your password to continue.",
@@ -280,6 +285,7 @@ if HAS_REAUTH:
def on_submit_success(self, request: HttpRequest) -> dict | None: def on_submit_success(self, request: HttpRequest) -> dict | None:
from allauth.account.internal.flows import reauthentication from allauth.account.internal.flows import reauthentication
reauthentication.reauthenticate_by_password(request) reauthentication.reauthenticate_by_password(request)
return None return None
@@ -289,10 +295,11 @@ if HAS_REAUTH:
# ============================================================================= # =============================================================================
if HAS_MFA: if HAS_MFA:
class DjareaMFAAuthenticateForm(MFAAuthenticateForm, DjareaFormMixin):
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
"""Authenticate with MFA during login.""" """Authenticate with MFA during login."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="mfa_authenticate", name="mfa_authenticate",
title="Two-Factor Authentication", title="Two-Factor Authentication",
subtitle="Enter your authentication code to continue.", subtitle="Enter your authentication code to continue.",
@@ -307,10 +314,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaMFAReauthenticateForm(MFAReauthenticateForm, DjareaFormMixin): class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
"""Re-authenticate with MFA for sensitive actions.""" """Re-authenticate with MFA for sensitive actions."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="mfa_reauthenticate", name="mfa_reauthenticate",
title="Confirm Your Identity", title="Confirm Your Identity",
subtitle="Enter your authentication code to continue.", subtitle="Enter your authentication code to continue.",
@@ -325,10 +332,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaActivateTOTPForm(ActivateTOTPForm, DjareaFormMixin): class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
"""Activate TOTP authenticator.""" """Activate TOTP authenticator."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="activate_totp", name="activate_totp",
title="Set Up Authenticator", title="Set Up Authenticator",
subtitle="Enter the code from your authenticator app to complete setup.", subtitle="Enter the code from your authenticator app to complete setup.",
@@ -343,10 +350,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaDeactivateTOTPForm(DeactivateTOTPForm, DjareaFormMixin): class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
"""Deactivate TOTP authenticator.""" """Deactivate TOTP authenticator."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="deactivate_totp", name="deactivate_totp",
title="Disable Authenticator", title="Disable Authenticator",
subtitle="Enter your password to disable two-factor authentication.", subtitle="Enter your password to disable two-factor authentication.",
@@ -361,10 +368,10 @@ if HAS_MFA:
self.save() self.save()
return None return None
class DjareaGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, DjareaFormMixin): class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
"""Generate new recovery codes.""" """Generate new recovery codes."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="generate_recovery_codes", name="generate_recovery_codes",
title="Recovery Codes", title="Recovery Codes",
subtitle="Generate new recovery codes for your account.", subtitle="Generate new recovery codes for your account.",
@@ -381,10 +388,11 @@ if HAS_MFA:
if HAS_WEBAUTHN: if HAS_WEBAUTHN:
class DjareaAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, DjareaFormMixin):
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
"""Authenticate with WebAuthn security key.""" """Authenticate with WebAuthn security key."""
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="webauthn_authenticate", name="webauthn_authenticate",
title="Security Key", title="Security Key",
subtitle="Use your security key to authenticate.", subtitle="Use your security key to authenticate.",

View File

@@ -1,5 +1,5 @@
""" """
djarea.jwt - JWT authentication for server functions. mizan.jwt - JWT authentication for server functions.
Provides: Provides:
- Server functions for obtaining/refreshing JWT tokens - Server functions for obtaining/refreshing JWT tokens
@@ -10,10 +10,10 @@ Server Functions:
- jwt_refresh: Refresh tokens using a refresh token - jwt_refresh: Refresh tokens using a refresh token
Usage in apps.py or urls.py (to register the functions): 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. Note: This module is purpose-built for mizan server functions.
For Django Ninja API authentication, use djarea.jwt.security directly. For Django Ninja API authentication, use mizan.jwt.security directly.
""" """
# Server functions (import to register with @client decorator) # 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 # Security (Ninja API auth) - lazy import to avoid triggering
# django-ninja's settings access at module load time. # 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): def __getattr__(name):
if name in ("JWTAuth", "jwt_auth"): if name in ("JWTAuth", "jwt_auth"):
from .security import JWTAuth, jwt_auth from .security import JWTAuth, jwt_auth
globals()["JWTAuth"] = JWTAuth globals()["JWTAuth"] = JWTAuth
globals()["jwt_auth"] = jwt_auth globals()["jwt_auth"] = jwt_auth
return globals()[name] return globals()[name]

View File

@@ -1,19 +1,20 @@
""" """
JWT Server Functions 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. Works over WebSocket RPC (primary) or HTTP fallback.
""" """
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client import client from mizan.client import client
from djarea.jwt.tokens import create_token_pair, refresh_tokens from mizan.jwt.tokens import create_token_pair, refresh_tokens
class TokenPairOutput(BaseModel): class TokenPairOutput(BaseModel):
"""JWT token pair response.""" """JWT token pair response."""
access_token: str access_token: str
refresh_token: str refresh_token: str
expires_in: int expires_in: int
@@ -21,6 +22,7 @@ class TokenPairOutput(BaseModel):
class JWTError(BaseModel): class JWTError(BaseModel):
"""JWT operation error.""" """JWT operation error."""
error: str error: str
@@ -45,10 +47,12 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
# Get session key - for WebSocket, this comes from the scope # Get session key - for WebSocket, this comes from the scope
session = getattr(request, 'session', None) session = getattr(request, "session", None)
if session is None: if session is None:
# WebSocket request adapter - session is a dict, not SessionBase # 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: if not session_key:
raise PermissionError("No session available") raise PermissionError("No session available")
else: else:
@@ -61,8 +65,8 @@ def jwt_obtain(request: HttpRequest) -> TokenPairOutput:
tokens = create_token_pair( tokens = create_token_pair(
user.pk, user.pk,
session_key, session_key,
is_staff=getattr(user, 'is_staff', False), is_staff=getattr(user, "is_staff", False),
is_superuser=getattr(user, 'is_superuser', False), is_superuser=getattr(user, "is_superuser", False),
) )
return TokenPairOutput( return TokenPairOutput(

View File

@@ -25,7 +25,7 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): 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() schema = get_channels_openapi_schema()

View File

@@ -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. The schema is consumed by openapi-typescript for robust type generation.
Usage: Usage:
python manage.py export_djarea_schema # Output to stdout python manage.py export_mizan_schema # Output to stdout
python manage.py export_djarea_schema --output schema.json # Output to file python manage.py export_mizan_schema --output schema.json # Output to file
""" """
import json import json
@@ -14,11 +14,11 @@ from pathlib import Path
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from djarea.export import generate_openapi_schema from mizan.export import generate_openapi_schema
class Command(BaseCommand): 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): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
@@ -44,8 +44,6 @@ class Command(BaseCommand):
output_path = Path(options["output"]) output_path = Path(options["output"])
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json_output) output_path.write_text(json_output)
self.stdout.write( self.stdout.write(self.style.SUCCESS(f"Schema written to {output_path}"))
self.style.SUCCESS(f"Schema written to {output_path}")
)
else: else:
self.stdout.write(json_output) self.stdout.write(json_output)

View File

@@ -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 - Registry for server functions and channels
- Auto-discovery for apps - Auto-discovery for apps
- Configuration settings - Configuration settings
Usage: Usage:
from djarea.setup import djarea_clients, register, get_function from mizan.setup import mizan_clients, register, get_function
""" """
from .registry import ( from .registry import (
@@ -30,12 +30,12 @@ from .registry import (
) )
from .discovery import ( from .discovery import (
djarea_clients, mizan_clients,
djarea_module, mizan_module,
) )
from .settings import ( from .settings import (
DjareaSettings, mizanSettings,
get_settings, get_settings,
clear_settings_cache, clear_settings_cache,
) )
@@ -60,10 +60,10 @@ __all__ = [
"get_forms", "get_forms",
"clear_registry", "clear_registry",
# Discovery # Discovery
"djarea_clients", "mizan_clients",
"djarea_module", "mizan_module",
# Settings # Settings
"DjareaSettings", "mizanSettings",
"get_settings", "get_settings",
"clear_settings_cache", "clear_settings_cache",
] ]

View File

@@ -1,25 +1,25 @@
""" """
Djarea Auto-Discovery mizan Auto-Discovery
Scans Django apps for server functions following the 'clients' layer convention: Scans Django apps for server functions following the 'clients' layer convention:
- <app>/clients.py - <app>/clients.py
- <app>/clients/**/*.py - <app>/clients/**/*.py
Usage in urls.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 mizan_clients('apps') # Scans apps/*/clients.py
djarea_clients('djarea', 'allauth') # Scans djarea/allauth/**/*.py mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
This replaces manual "import to register" patterns with explicit auto-discovery. This replaces manual "import to register" patterns with explicit auto-discovery.
""" """
from typing import Any 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 .registry import register, get_function
from djarea.client.function import ServerFunction from mizan.client.function import ServerFunction
class _RegisterServerFunctions: class _RegisterServerFunctions:
@@ -35,10 +35,10 @@ class _RegisterServerFunctions:
isinstance(member, type) isinstance(member, type)
and issubclass(member, ServerFunction) and issubclass(member, ServerFunction)
and member is not ServerFunction and member is not ServerFunction
and hasattr(member, '__name__') and hasattr(member, "__name__")
): ):
# Use the function name as registration 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) # Skip already registered (idempotent)
if get_function(fn_name) is member: if get_function(fn_name) is member:
@@ -51,7 +51,7 @@ class _RegisterServerFunctions:
pass 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. Discover and register server functions from Django apps.
@@ -65,26 +65,26 @@ def djarea_clients(apps_root: str, layer: str = 'clients') -> None:
Example: Example:
# In urls.py # In urls.py
djarea_clients('apps') # Scans apps/*/clients.py mizan_clients('apps') # Scans apps/*/clients.py
djarea_clients('apps', 'functions') # Scans apps/*/functions.py mizan_clients('apps', 'functions') # Scans apps/*/functions.py
""" """
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root) visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
visitor.visit(_RegisterServerFunctions()) visitor.visit(_RegisterServerFunctions())
def djarea_module(module_path: str) -> None: def mizan_module(module_path: str) -> None:
""" """
Register server functions from a specific module. Register server functions from a specific module.
Use this for library modules that don't follow the app convention. Use this for library modules that don't follow the app convention.
Args: Args:
module_path: Full module path (e.g., 'djarea.integrations.allauth') module_path: Full module path (e.g., 'mizan.integrations.allauth')
Example: Example:
djarea_module('djarea.integrations.allauth') mizan_module('mizan.integrations.allauth')
djarea_module('djarea.jwt.functions') mizan_module('mizan.jwt.functions')
""" """
members = get_members(module_path) members = get_members(module_path)
handler = _RegisterServerFunctions() handler = _RegisterServerFunctions()
handler.on_module('', [], members) handler.on_module("", [], members)

View File

@@ -1,5 +1,5 @@
""" """
Djarea Registry mizan Registry
Central registration for server functions, channels, and compositions. Central registration for server functions, channels, and compositions.
All items are identified by name. All items are identified by name.
@@ -10,8 +10,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING: if TYPE_CHECKING:
from djarea.client.function import ServerFunction, ComposedContext from mizan.client.function import ServerFunction, ComposedContext
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
# Global registries - all use name as key # Global registries - all use name as key
@@ -34,8 +34,8 @@ def register(
Returns: Returns:
The view class (allows use as part of decorator chain) The view class (allows use as part of decorator chain)
""" """
from djarea.client.function import ServerFunction from mizan.client.function import ServerFunction
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
view_class.name = name view_class.name = name
@@ -98,7 +98,7 @@ def register_form(
Usage: Usage:
register_form(ContactForm, 'contact', submit_handler=handle_contact) 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( schema_fn, validate_fn, submit_fn = create_form_functions(
form_class, name, submit_handler form_class, name, submit_handler
@@ -130,9 +130,7 @@ def register_compose(
# Same composition being re-registered (reload scenario) # Same composition being re-registered (reload scenario)
_compositions[name] = composed _compositions[name] = composed
return composed return composed
raise ValueError( raise ValueError(f"Composition '{name}' already registered by {existing.name}")
f"Composition '{name}' already registered by {existing.name}"
)
_compositions[name] = composed _compositions[name] = composed
return composed return composed
@@ -254,17 +252,21 @@ def get_schema() -> dict[str, Any]:
} }
# Extract Params schema (only if defined) # 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() channel_schema["params"] = channel_class.Params.model_json_schema()
# Extract ReactMessage schema (only if defined - indicates bidirectional) # Extract ReactMessage schema (only if defined - indicates bidirectional)
if hasattr(channel_class, 'ReactMessage') and channel_class.ReactMessage: if hasattr(channel_class, "ReactMessage") and channel_class.ReactMessage:
channel_schema["react_message"] = channel_class.ReactMessage.model_json_schema() channel_schema[
"react_message"
] = channel_class.ReactMessage.model_json_schema()
channel_schema["bidirectional"] = True channel_schema["bidirectional"] = True
# Extract DjangoMessage schema (only if defined) # Extract DjangoMessage schema (only if defined)
if hasattr(channel_class, 'DjangoMessage') and channel_class.DjangoMessage: if hasattr(channel_class, "DjangoMessage") and channel_class.DjangoMessage:
channel_schema["django_message"] = channel_class.DjangoMessage.model_json_schema() channel_schema[
"django_message"
] = channel_class.DjangoMessage.model_json_schema()
channels_schema[name] = channel_schema channels_schema[name] = channel_schema

View File

@@ -1,5 +1,5 @@
""" """
Djarea Settings mizan Settings
Configuration is read from Django settings with sensible defaults. Configuration is read from Django settings with sensible defaults.
""" """
@@ -11,23 +11,23 @@ from django.conf import settings as django_settings
@dataclass @dataclass
class DjareaSettings: class mizanSettings:
"""Djarea configuration.""" """mizan configuration."""
# Whether to expose function names in DEBUG mode errors # Whether to expose function names in DEBUG mode errors
debug_expose_names: bool debug_expose_names: bool
@lru_cache @lru_cache
def get_settings() -> DjareaSettings: def get_settings() -> mizanSettings:
""" """
Load Djarea settings from Django settings. Load mizan settings from Django settings.
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( return mizanSettings(
debug_expose_names=getattr(django_settings, "DJAREA_DEBUG_EXPOSE_NAMES", True), debug_expose_names=getattr(django_settings, "mizan_DEBUG_EXPOSE_NAMES", True),
) )

View File

@@ -0,0 +1,3 @@
from mizan.shapes.core import Diff, NestedDiff, Shape
__all__ = ["Diff", "NestedDiff", "Shape"]

View File

@@ -1,5 +1,5 @@
""" """
Authentication Tests for Djarea Server Functions Authentication Tests for mizan Server Functions
Tests all combinations of: Tests all combinations of:
- Transport: HTTP vs WebSocket RPC - Transport: HTTP vs WebSocket RPC
@@ -19,20 +19,20 @@ from django.contrib.sessions.backends.db import SessionStore
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import json import json
from djarea.jwt.tokens import ( from mizan.jwt.tokens import (
create_token_pair, create_token_pair,
decode_token, decode_token,
JWTUser, JWTUser,
) )
from djarea.client.executor import ( from mizan.client.executor import (
_try_jwt_auth, _try_jwt_auth,
execute_function, execute_function,
FunctionError, FunctionError,
FunctionResult, FunctionResult,
ErrorCode, ErrorCode,
) )
from djarea.client import client from mizan.client import client
from djarea.setup.registry import clear_registry, register from mizan.setup.registry import clear_registry, register
from pydantic import BaseModel from pydantic import BaseModel
@@ -43,6 +43,7 @@ User = get_user_model()
# Test Output Models (proper Pydantic models, not raw dicts) # Test Output Models (proper Pydantic models, not raw dicts)
# ============================================================================= # =============================================================================
class WhoamiOutput(BaseModel): class WhoamiOutput(BaseModel):
is_authenticated: bool is_authenticated: bool
user_id: int | None user_id: int | None
@@ -62,6 +63,7 @@ class UserTypeOutput(BaseModel):
# Test Server Functions - defined as plain functions, registered in setUp # Test Server Functions - defined as plain functions, registered in setUp
# ============================================================================= # =============================================================================
def _whoami_fn(request) -> WhoamiOutput: def _whoami_fn(request) -> WhoamiOutput:
"""Returns info about the authenticated user.""" """Returns info about the authenticated user."""
user = request.user user = request.user
@@ -104,6 +106,7 @@ class HTTPAuthTests(TestCase):
user_type=type(user).__name__, user_type=type(user).__name__,
is_staff=getattr(user, "is_staff", False), is_staff=getattr(user, "is_staff", False),
) )
register(whoami, "whoami") register(whoami, "whoami")
def tearDown(self): def tearDown(self):
@@ -168,7 +171,7 @@ class HTTPAuthTests(TestCase):
def test_jwt_expired_with_session(self): def test_jwt_expired_with_session(self):
"""Expired JWT with valid session → Reject (do NOT fall back).""" """Expired JWT with valid session → Reject (do NOT fall back)."""
# Create token with past expiration by mocking time # 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( tokens = create_token_pair(
self.user.pk, self.user.pk,
self.session_key, self.session_key,
@@ -248,7 +251,7 @@ class JWTUserTests(TestCase):
def test_jwt_user_attributes(self): def test_jwt_user_attributes(self):
"""JWTUser has expected attributes.""" """JWTUser has expected attributes."""
from djarea.jwt.tokens import TokenPayload from mizan.jwt.tokens import TokenPayload
payload = TokenPayload( payload = TokenPayload(
user_id=42, user_id=42,
@@ -272,7 +275,7 @@ class JWTUserTests(TestCase):
def test_jwt_user_string_id(self): def test_jwt_user_string_id(self):
"""JWTUser handles string user_id (converted to int).""" """JWTUser handles string user_id (converted to int)."""
from djarea.jwt.tokens import TokenPayload from mizan.jwt.tokens import TokenPayload
payload = TokenPayload( payload = TokenPayload(
user_id="42", # String, as stored in JWT user_id="42", # String, as stored in JWT
@@ -333,6 +336,7 @@ class AuthDecoratorTests(TestCase):
@client(auth=True) @client(auth=True)
def protected_fn(request) -> OkOutput: def protected_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(protected_fn, "protected_fn") register(protected_fn, "protected_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -345,9 +349,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_required_with_authenticated(self): def test_auth_required_with_authenticated(self):
"""@client(auth=True) allows authenticated users.""" """@client(auth=True) allows authenticated users."""
@client(auth=True) @client(auth=True)
def protected_fn2(request) -> OkOutput: def protected_fn2(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(protected_fn2, "protected_fn2") register(protected_fn2, "protected_fn2")
request = self.factory.post("/") request = self.factory.post("/")
@@ -360,9 +366,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_staff_with_regular_user(self): def test_auth_staff_with_regular_user(self):
"""@client(auth='staff') rejects non-staff users.""" """@client(auth='staff') rejects non-staff users."""
@client(auth='staff')
@client(auth="staff")
def staff_fn(request) -> OkOutput: def staff_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(staff_fn, "staff_fn") register(staff_fn, "staff_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -375,9 +383,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_staff_with_staff_user(self): def test_auth_staff_with_staff_user(self):
"""@client(auth='staff') allows staff users.""" """@client(auth='staff') allows staff users."""
@client(auth='staff')
@client(auth="staff")
def staff_fn2(request) -> OkOutput: def staff_fn2(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(staff_fn2, "staff_fn2") register(staff_fn2, "staff_fn2")
request = self.factory.post("/") request = self.factory.post("/")
@@ -389,9 +399,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_superuser_with_staff(self): def test_auth_superuser_with_staff(self):
"""@client(auth='superuser') rejects non-superusers.""" """@client(auth='superuser') rejects non-superusers."""
@client(auth='superuser')
@client(auth="superuser")
def super_fn(request) -> OkOutput: def super_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(super_fn, "super_fn") register(super_fn, "super_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -404,9 +416,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_superuser_with_superuser(self): def test_auth_superuser_with_superuser(self):
"""@client(auth='superuser') allows superusers.""" """@client(auth='superuser') allows superusers."""
@client(auth='superuser')
@client(auth="superuser")
def super_fn2(request) -> OkOutput: def super_fn2(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(super_fn2, "super_fn2") register(super_fn2, "super_fn2")
request = self.factory.post("/") request = self.factory.post("/")
@@ -418,11 +432,12 @@ class AuthDecoratorTests(TestCase):
def test_auth_with_jwt_user(self): def test_auth_with_jwt_user(self):
"""Auth checks work with JWTUser (stateless).""" """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: def jwt_staff_fn(request) -> UserTypeOutput:
return UserTypeOutput(user_type=type(request.user).__name__) return UserTypeOutput(user_type=type(request.user).__name__)
register(jwt_staff_fn, "jwt_staff_fn") register(jwt_staff_fn, "jwt_staff_fn")
# Create JWTUser with is_staff=True # Create JWTUser with is_staff=True
@@ -448,7 +463,8 @@ class AuthDecoratorTests(TestCase):
def test_auth_invalid_string_raises(self): def test_auth_invalid_string_raises(self):
"""Invalid auth string raises ValueError at decoration time.""" """Invalid auth string raises ValueError at decoration time."""
with self.assertRaises(ValueError) as ctx: 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: def bad_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
@@ -457,9 +473,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_callable_returns_true(self): def test_auth_callable_returns_true(self):
"""Callable auth returning True allows access.""" """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: def email_check_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(email_check_fn, "email_check_fn") register(email_check_fn, "email_check_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -472,9 +490,11 @@ class AuthDecoratorTests(TestCase):
def test_auth_callable_returns_false(self): def test_auth_callable_returns_false(self):
"""Callable auth returning False denies access.""" """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: def admin_email_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(admin_email_fn, "admin_email_fn") register(admin_email_fn, "admin_email_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -488,14 +508,16 @@ class AuthDecoratorTests(TestCase):
def test_auth_callable_raises_permission_error(self): def test_auth_callable_raises_permission_error(self):
"""Callable auth raising PermissionError uses custom message.""" """Callable auth raising PermissionError uses custom message."""
def check_premium(request): 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") raise PermissionError("Premium subscription required")
return True return True
@client(auth=check_premium) @client(auth=check_premium)
def premium_fn(request) -> OkOutput: def premium_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(premium_fn, "premium_fn") register(premium_fn, "premium_fn")
request = self.factory.post("/") request = self.factory.post("/")
@@ -519,6 +541,7 @@ class AuthDecoratorTests(TestCase):
@client(auth=must_be_authenticated) @client(auth=must_be_authenticated)
def needs_login_fn(request) -> OkOutput: def needs_login_fn(request) -> OkOutput:
return OkOutput(ok=True) return OkOutput(ok=True)
register(needs_login_fn, "needs_login_fn") register(needs_login_fn, "needs_login_fn")
request = self.factory.post("/") request = self.factory.post("/")

View File

@@ -5,7 +5,7 @@ Compares performance of HTTP POST vs WebSocket RPC for server function calls.
Includes realistic scenarios with ORM queries. Includes realistic scenarios with ORM queries.
Usage: Usage:
python manage.py test djarea.tests.test_benchmarks --verbosity=2 python manage.py test mizan.tests.test_benchmarks --verbosity=2
Note: Note:
These are not unit tests - they measure performance. Results are printed 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 django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client.executor import FunctionResult, execute_function, function_call_view from mizan.client.executor import FunctionResult, execute_function, function_call_view
from djarea.setup.registry import clear_registry from mizan.setup.registry import clear_registry
from djarea.client import client from mizan.client import client
User = get_user_model() User = get_user_model()
@@ -66,7 +66,7 @@ class StatsOutput(BaseModel):
def setup_benchmark_functions(): def setup_benchmark_functions():
"""Register benchmark server functions.""" """Register benchmark server functions."""
from djarea.setup.registry import register from mizan.setup.registry import register
clear_registry() clear_registry()
@@ -75,6 +75,7 @@ def setup_benchmark_functions():
def bench_simple(request: HttpRequest, a: int, b: int) -> SimpleOutput: def bench_simple(request: HttpRequest, a: int, b: int) -> SimpleOutput:
"""Simple addition - baseline with no I/O.""" """Simple addition - baseline with no I/O."""
return SimpleOutput(value=a + b) return SimpleOutput(value=a + b)
register(bench_simple, "bench_simple") register(bench_simple, "bench_simple")
# 2. Single ORM query # 2. Single ORM query
@@ -85,6 +86,7 @@ def setup_benchmark_functions():
if user: if user:
return UserOutput(id=user.id, email=user.email) return UserOutput(id=user.id, email=user.email)
return UserOutput(id=0, email="") return UserOutput(id=0, email="")
register(bench_get_user, "bench_get_user") register(bench_get_user, "bench_get_user")
# 3. List query with limit # 3. List query with limit
@@ -96,6 +98,7 @@ def setup_benchmark_functions():
users=[{"id": u.id, "email": u.email} for u in users], users=[{"id": u.id, "email": u.email} for u in users],
count=len(users), count=len(users),
) )
register(bench_list_users, "bench_list_users") register(bench_list_users, "bench_list_users")
# 4. Aggregation query # 4. Aggregation query
@@ -110,11 +113,14 @@ def setup_benchmark_functions():
active_users=active, active_users=active,
staff_count=staff, staff_count=staff,
) )
register(bench_user_stats, "bench_user_stats") register(bench_user_stats, "bench_user_stats")
# 5. Complex query with joins # 5. Complex query with joins
@client @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.""" """Search users by email pattern."""
users = User.objects.filter( users = User.objects.filter(
email__icontains=email_contains, email__icontains=email_contains,
@@ -124,6 +130,7 @@ def setup_benchmark_functions():
users=[{"id": u.id, "email": u.email} for u in users], users=[{"id": u.id, "email": u.email} for u in users],
count=len(users), count=len(users),
) )
register(bench_user_search, "bench_user_search") register(bench_user_search, "bench_user_search")
@@ -158,11 +165,13 @@ class ProtocolBenchmark(TransactionTestCase):
# Create 100 test users # Create 100 test users
users = [] users = []
for i in range(100): for i in range(100):
users.append(User( users.append(
email=f"bench{i}@example.com", User(
is_active=i % 10 != 0, # 90% active email=f"bench{i}@example.com",
is_staff=i < 5, # 5 staff is_active=i % 10 != 0, # 90% active
)) is_staff=i < 5, # 5 staff
)
)
User.objects.bulk_create(users, ignore_conflicts=True) User.objects.bulk_create(users, ignore_conflicts=True)
self.test_user = User.objects.first() self.test_user = User.objects.first()
@@ -170,12 +179,12 @@ class ProtocolBenchmark(TransactionTestCase):
"""Create a request with optional JSON body.""" """Create a request with optional JSON body."""
if body: if body:
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data=json.dumps(body), data=json.dumps(body),
content_type="application/json", content_type="application/json",
) )
else: else:
request = self.factory.post("/api/djarea/call/") request = self.factory.post("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
return request return request
@@ -245,12 +254,16 @@ class ProtocolBenchmark(TransactionTestCase):
print(f"{'Benchmark':<40} {'Mean':>8} {'Median':>8} {'P95':>8} {'P99':>8}") print(f"{'Benchmark':<40} {'Mean':>8} {'Median':>8} {'P95':>8} {'P99':>8}")
print("=" * 80) print("=" * 80)
for r in results: 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) print("=" * 80)
def _print_comparison(self, executor_stats: dict, http_stats: dict): def _print_comparison(self, executor_stats: dict, http_stats: dict):
"""Print comparison between executor and HTTP.""" """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}%") print(f" HTTP overhead vs Executor: {overhead:+.1f}%")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -400,18 +413,20 @@ class ThroughputBenchmark(TransactionTestCase):
"""Create test users for benchmarks.""" """Create test users for benchmarks."""
users = [] users = []
for i in range(100): for i in range(100):
users.append(User( users.append(
email=f"bench{i}@example.com", User(
is_active=i % 10 != 0, email=f"bench{i}@example.com",
is_staff=i < 5, is_active=i % 10 != 0,
)) is_staff=i < 5,
)
)
User.objects.bulk_create(users, ignore_conflicts=True) User.objects.bulk_create(users, ignore_conflicts=True)
self.test_user = User.objects.first() self.test_user = User.objects.first()
def _make_request(self, body: dict) -> HttpRequest: def _make_request(self, body: dict) -> HttpRequest:
"""Create a POST request with JSON body.""" """Create a POST request with JSON body."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data=json.dumps(body), data=json.dumps(body),
content_type="application/json", content_type="application/json",
) )
@@ -470,7 +485,9 @@ class ThroughputBenchmark(TransactionTestCase):
"""Throughput: Simple computation (no I/O).""" """Throughput: Simple computation (no I/O)."""
print("\n\n### THROUGHPUT: Simple Computation ###") 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}) http_rps = self._measure_throughput_http("bench_simple", {"a": 1, "b": 2})
self._print_throughput("Simple (no I/O)", executor_rps, http_rps) self._print_throughput("Simple (no I/O)", executor_rps, http_rps)
@@ -502,7 +519,9 @@ class ThroughputBenchmark(TransactionTestCase):
"""Throughput: List query.""" """Throughput: List query."""
print("\n\n### THROUGHPUT: List Query (10 users) ###") 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}) http_rps = self._measure_throughput_http("bench_list_users", {"limit": 10})
self._print_throughput("List Query", executor_rps, http_rps) self._print_throughput("List Query", executor_rps, http_rps)

View File

@@ -1,5 +1,5 @@
""" """
Tests for djarea.channels module. Tests for mizan.channels module.
""" """
import json import json
@@ -8,7 +8,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from pydantic import BaseModel from pydantic import BaseModel
from djarea.channels import ( from mizan.channels import (
ReactChannel, ReactChannel,
register, register,
get_channel, get_channel,
@@ -25,8 +25,10 @@ User = get_user_model()
# Test Fixtures # Test Fixtures
# ============================================================================= # =============================================================================
class MockUser: class MockUser:
"""Mock user for testing.""" """Mock user for testing."""
def __init__(self, is_authenticated=True, email="test@example.com"): def __init__(self, is_authenticated=True, email="test@example.com"):
self.is_authenticated = is_authenticated self.is_authenticated = is_authenticated
self.email = email self.email = email
@@ -34,6 +36,7 @@ class MockUser:
class MockAnonymousUser: class MockAnonymousUser:
"""Mock anonymous user.""" """Mock anonymous user."""
is_authenticated = False is_authenticated = False
email = "" email = ""
@@ -42,6 +45,7 @@ class MockAnonymousUser:
# ReactChannel Base Class Tests # ReactChannel Base Class Tests
# ============================================================================= # =============================================================================
class ReactChannelBaseTests(TestCase): class ReactChannelBaseTests(TestCase):
"""Tests for ReactChannel base class.""" """Tests for ReactChannel base class."""
@@ -115,6 +119,7 @@ class ReactChannelBaseTests(TestCase):
# Channel with Typed Messages Tests # Channel with Typed Messages Tests
# ============================================================================= # =============================================================================
class TypedMessagesTests(TestCase): class TypedMessagesTests(TestCase):
"""Tests for channels with Pydantic message types.""" """Tests for channels with Pydantic message types."""
@@ -179,9 +184,7 @@ class TypedMessagesTests(TestCase):
# Test message model # Test message model
msg = BroadcastChannel.DjangoMessage( msg = BroadcastChannel.DjangoMessage(
user="john", user="john", text="Hello world", created_at="2024-01-15T10:00:00Z"
text="Hello world",
created_at="2024-01-15T10:00:00Z"
) )
self.assertEqual(msg.user, "john") self.assertEqual(msg.user, "john")
self.assertEqual(msg.text, "Hello world") self.assertEqual(msg.text, "Hello world")
@@ -207,10 +210,7 @@ class TypedMessagesTests(TestCase):
return f"chat_{params.room}" return f"chat_{params.room}"
def receive(self, params, msg): def receive(self, params, msg):
return self.DjangoMessage( return self.DjangoMessage(user=self.user.email, text=msg.text)
user=self.user.email,
text=msg.text
)
channel = ChatChannel() channel = ChatChannel()
channel.user = MockUser(email="test@example.com") channel.user = MockUser(email="test@example.com")
@@ -229,6 +229,7 @@ class TypedMessagesTests(TestCase):
# Registration Tests # Registration Tests
# ============================================================================= # =============================================================================
class RegistrationTests(TestCase): class RegistrationTests(TestCase):
"""Tests for channel registration.""" """Tests for channel registration."""
@@ -336,6 +337,7 @@ class RegistrationTests(TestCase):
# Schema Export Tests # Schema Export Tests
# ============================================================================= # =============================================================================
class SchemaExportTests(TestCase): class SchemaExportTests(TestCase):
"""Tests for channel schema export.""" """Tests for channel schema export."""
@@ -482,6 +484,7 @@ class SchemaExportTests(TestCase):
# Authorization Tests # Authorization Tests
# ============================================================================= # =============================================================================
class AuthorizationTests(TestCase): class AuthorizationTests(TestCase):
"""Tests for channel authorization.""" """Tests for channel authorization."""
@@ -543,6 +546,7 @@ class AuthorizationTests(TestCase):
# Group Tests # Group Tests
# ============================================================================= # =============================================================================
class GroupTests(TestCase): class GroupTests(TestCase):
"""Tests for channel group management.""" """Tests for channel group management."""
@@ -586,6 +590,7 @@ class GroupTests(TestCase):
# Async Methods Tests # Async Methods Tests
# ============================================================================= # =============================================================================
class AsyncMethodsTests(TestCase): class AsyncMethodsTests(TestCase):
"""Tests for async internal methods.""" """Tests for async internal methods."""
@@ -727,6 +732,7 @@ class AsyncMethodsTests(TestCase):
# Server Push Tests # Server Push Tests
# ============================================================================= # =============================================================================
class ServerPushTests(TestCase): class ServerPushTests(TestCase):
"""Tests for server push functionality.""" """Tests for server push functionality."""
@@ -752,13 +758,12 @@ class ServerPushTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "notifications" 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_layer = AsyncMock()
mock_get_layer.return_value = mock_layer mock_get_layer.return_value = mock_layer
message = NotificationChannel.DjangoMessage( message = NotificationChannel.DjangoMessage(
title="Alert", title="Alert", body="Something happened"
body="Something happened"
) )
async def test(): async def test():
@@ -789,7 +794,7 @@ class ServerPushTests(TestCase):
def group(self, params): def group(self, params):
return f"room_{params.room}" 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_layer = AsyncMock()
mock_get_layer.return_value = mock_layer mock_get_layer.return_value = mock_layer
@@ -821,24 +826,28 @@ class ServerPushTests(TestCase):
def group(self, params=None): def group(self, params=None):
return "test" 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 mock_get_layer.return_value = None
message = TestChannel.DjangoMessage(text="test") 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(): async def test():
await TestChannel.push(message=message) await TestChannel.push(message=message)
asyncio.get_event_loop().run_until_complete(test()) 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 # Management Command Tests
# ============================================================================= # =============================================================================
class ManagementCommandTests(TestCase): class ManagementCommandTests(TestCase):
"""Tests for the export_channels_schema management command.""" """Tests for the export_channels_schema management command."""
@@ -855,7 +864,7 @@ class ManagementCommandTests(TestCase):
from django.core.management import call_command from django.core.management import call_command
out = StringIO() out = StringIO()
call_command('export_channels_schema', stdout=out) call_command("export_channels_schema", stdout=out)
output = out.getvalue() output = out.getvalue()
@@ -863,7 +872,7 @@ class ManagementCommandTests(TestCase):
schema = json.loads(output) schema = json.loads(output)
self.assertIn("openapi", schema) self.assertIn("openapi", schema)
self.assertIn("x-djarea-channels", schema) self.assertIn("x-mizan-channels", schema)
def test_export_command_includes_registered_channels(self): def test_export_command_includes_registered_channels(self):
"""export_channels_schema should include registered channels.""" """export_channels_schema should include registered channels."""
@@ -883,13 +892,13 @@ class ManagementCommandTests(TestCase):
register(TestChannel, "export-test") register(TestChannel, "export-test")
out = StringIO() out = StringIO()
call_command('export_channels_schema', stdout=out) call_command("export_channels_schema", stdout=out)
output = out.getvalue() output = out.getvalue()
schema = json.loads(output) schema = json.loads(output)
# Check that channel is in x-djarea-channels metadata # Check that channel is in x-mizan-channels metadata
channel_names = [c["name"] for c in schema["x-djarea-channels"]] channel_names = [c["name"] for c in schema["x-mizan-channels"]]
self.assertIn("export-test", channel_names) self.assertIn("export-test", channel_names)
def test_export_command_respects_indent(self): def test_export_command_respects_indent(self):
@@ -899,11 +908,11 @@ class ManagementCommandTests(TestCase):
# With indent # With indent
out_indent = StringIO() 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) # Without indent (compact)
out_compact = StringIO() 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) # Indented should be longer (has whitespace)
self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue())) self.assertGreater(len(out_indent.getvalue()), len(out_compact.getvalue()))
@@ -918,13 +927,14 @@ class WebSocketRPCTests(TestCase):
"""Tests for WebSocket RPC functionality.""" """Tests for WebSocket RPC functionality."""
def setUp(self): def setUp(self):
# Clear djarea registry # Clear mizan registry
from djarea.setup.registry import clear_registry from mizan.setup.registry import clear_registry
clear_registry() clear_registry()
# Register test functions # Register test functions
from djarea.client import client from mizan.client import client
from djarea.setup.registry import register from mizan.setup.registry import register
from pydantic import BaseModel from pydantic import BaseModel
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
@@ -936,11 +946,13 @@ class WebSocketRPCTests(TestCase):
@client(websocket=True) @client(websocket=True)
def rpc_echo(request, message: str) -> EchoOutput: def rpc_echo(request, message: str) -> EchoOutput:
return EchoOutput(echo=f"Echo: {message}") return EchoOutput(echo=f"Echo: {message}")
register(rpc_echo, "rpc_echo") register(rpc_echo, "rpc_echo")
@client(websocket=True) @client(websocket=True)
def rpc_add(request, a: int, b: int) -> AddOutput: def rpc_add(request, a: int, b: int) -> AddOutput:
return AddOutput(result=a + b) return AddOutput(result=a + b)
register(rpc_add, "rpc_add") register(rpc_add, "rpc_add")
@client(websocket=True) @client(websocket=True)
@@ -948,16 +960,18 @@ class WebSocketRPCTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
return EchoOutput(echo=f"Hello, {request.user.email}") return EchoOutput(echo=f"Hello, {request.user.email}")
register(rpc_auth_required, "rpc_auth_required") register(rpc_auth_required, "rpc_auth_required")
def tearDown(self): def tearDown(self):
from djarea.setup.registry import clear_registry from mizan.setup.registry import clear_registry
clear_registry() clear_registry()
def test_handle_rpc_success(self): def test_handle_rpc_success(self):
"""_handle_rpc should execute function and return result.""" """_handle_rpc should execute function and return result."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = { consumer.scope = {
@@ -971,11 +985,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-123", {
"fn": "rpc_echo", "id": "test-123",
"args": {"message": "Hello"}, "fn": "rpc_echo",
}) "args": {"message": "Hello"},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -989,7 +1005,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_with_multiple_args(self): def test_handle_rpc_with_multiple_args(self):
"""_handle_rpc should handle functions with multiple arguments.""" """_handle_rpc should handle functions with multiple arguments."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1001,11 +1017,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "add-123", {
"fn": "rpc_add", "id": "add-123",
"args": {"a": 5, "b": 3}, "fn": "rpc_add",
}) "args": {"a": 5, "b": 3},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1016,7 +1034,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_function_not_found(self): def test_handle_rpc_function_not_found(self):
"""_handle_rpc should return error for unknown function.""" """_handle_rpc should return error for unknown function."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1028,11 +1046,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-456", {
"fn": "nonexistent_function", "id": "test-456",
"args": {}, "fn": "nonexistent_function",
}) "args": {},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1044,7 +1064,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_validation_error(self): def test_handle_rpc_validation_error(self):
"""_handle_rpc should return validation error for invalid input.""" """_handle_rpc should return validation error for invalid input."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1056,11 +1076,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-789", {
"fn": "rpc_echo", "id": "test-789",
"args": {}, # Missing required 'message' field "fn": "rpc_echo",
}) "args": {}, # Missing required 'message' field
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1072,7 +1094,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_missing_id(self): def test_handle_rpc_missing_id(self):
"""_handle_rpc should return error when id is missing.""" """_handle_rpc should return error when id is missing."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1084,11 +1106,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"fn": "rpc_echo", {
"args": {"message": "test"}, "fn": "rpc_echo",
# Missing 'id' "args": {"message": "test"},
}) # Missing 'id'
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1099,7 +1123,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_missing_fn(self): def test_handle_rpc_missing_fn(self):
"""_handle_rpc should return error when fn is missing.""" """_handle_rpc should return error when fn is missing."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockUser()} consumer.scope = {"user": MockUser()}
@@ -1111,11 +1135,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "test-abc", {
"args": {"message": "test"}, "id": "test-abc",
# Missing 'fn' "args": {"message": "test"},
}) # Missing 'fn'
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1127,7 +1153,7 @@ class WebSocketRPCTests(TestCase):
def test_handle_rpc_with_unauthenticated_user(self): def test_handle_rpc_with_unauthenticated_user(self):
"""_handle_rpc should handle permission errors correctly.""" """_handle_rpc should handle permission errors correctly."""
import asyncio import asyncio
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": MockAnonymousUser()} consumer.scope = {"user": MockAnonymousUser()}
@@ -1139,11 +1165,13 @@ class WebSocketRPCTests(TestCase):
consumer.send_json = mock_send_json consumer.send_json = mock_send_json
async def test(): async def test():
await consumer._handle_rpc({ await consumer._handle_rpc(
"id": "auth-test", {
"fn": "rpc_auth_required", "id": "auth-test",
"args": {}, "fn": "rpc_auth_required",
}) "args": {},
}
)
asyncio.get_event_loop().run_until_complete(test()) asyncio.get_event_loop().run_until_complete(test())
@@ -1154,7 +1182,7 @@ class WebSocketRPCTests(TestCase):
def test_websocket_request_adapter(self): def test_websocket_request_adapter(self):
"""WebSocketRequest should provide correct user and session.""" """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") mock_user = MockUser(email="ws@example.com")
scope = { scope = {

View File

@@ -1,5 +1,5 @@
""" """
Tests for Djarea server functions. Tests for mizan server functions.
""" """
import json import json
@@ -10,10 +10,23 @@ from django.http import HttpRequest
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from djarea.client.executor import ErrorCode, FunctionError, FunctionResult, execute_function from mizan.client.executor import (
from djarea.setup.registry import clear_registry, register, register_as, register_form, get_schema, get_contexts, get_function ErrorCode,
from djarea.client import ServerFunction, client FunctionError,
from djarea.channels import ReactChannel 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. """Register function-style test functions.
Note: Since @client no longer auto-registers (registration happens via 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 @client
def fn_echo(request: HttpRequest, message: str) -> EchoOutput: def fn_echo(request: HttpRequest, message: str) -> EchoOutput:
return EchoOutput(echo=f"Echo: {message}") return EchoOutput(echo=f"Echo: {message}")
register(fn_echo, "fn_echo") register(fn_echo, "fn_echo")
@client @client
def fn_no_input(request: HttpRequest) -> ValueOutput: def fn_no_input(request: HttpRequest) -> ValueOutput:
return ValueOutput(value=42) return ValueOutput(value=42)
register(fn_no_input, "fn_no_input") register(fn_no_input, "fn_no_input")
@client @client
@@ -68,6 +83,7 @@ def setup_function_style_tests():
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
return UserEmailOutput(user_email=request.user.email) return UserEmailOutput(user_email=request.user.email)
register(fn_auth_required, "fn_auth_required") register(fn_auth_required, "fn_auth_required")
@client @client
@@ -78,11 +94,13 @@ def setup_function_style_tests():
if age > 150: if age > 150:
raise ValueError("Age must be realistic") raise ValueError("Age must be realistic")
return ValidOutput(valid=True) return ValidOutput(valid=True)
register(fn_validation, "fn_validation") register(fn_validation, "fn_validation")
@client @client
def fn_error(request: HttpRequest) -> ErrorOutput: def fn_error(request: HttpRequest) -> ErrorOutput:
raise RuntimeError("Something went wrong") raise RuntimeError("Something went wrong")
register(fn_error, "fn_error") register(fn_error, "fn_error")
@@ -401,6 +419,7 @@ class RegistryTests(TestCase):
@client @client
def decorated_fn(request: HttpRequest) -> TestOutput: def decorated_fn(request: HttpRequest) -> TestOutput:
return TestOutput(result="success") return TestOutput(result="success")
register(decorated_fn, "decorated_fn") register(decorated_fn, "decorated_fn")
fn = get_function("decorated_fn") fn = get_function("decorated_fn")
@@ -424,6 +443,7 @@ class RegistryTests(TestCase):
@client @client
def my_client(request: HttpRequest) -> AutoOutput: def my_client(request: HttpRequest) -> AutoOutput:
return AutoOutput(value=1) return AutoOutput(value=1)
register(my_client, "my_client") register(my_client, "my_client")
# Name is the function name, not kebab-case # Name is the function name, not kebab-case
@@ -484,9 +504,10 @@ class ContextTests(TestCase):
class CtxOutput(BaseModel): class CtxOutput(BaseModel):
data: str data: str
@client(context='global') @client(context="global")
def global_context(request: HttpRequest) -> CtxOutput: def global_context(request: HttpRequest) -> CtxOutput:
return CtxOutput(data="test") return CtxOutput(data="test")
register(global_context, "global_context") register(global_context, "global_context")
fn = get_function("global_context") fn = get_function("global_context")
@@ -498,9 +519,10 @@ class ContextTests(TestCase):
class CtxOutput(BaseModel): class CtxOutput(BaseModel):
data: str data: str
@client(context='local') @client(context="local")
def local_context(request: HttpRequest, user_id: int) -> CtxOutput: def local_context(request: HttpRequest, user_id: int) -> CtxOutput:
return CtxOutput(data=f"user_{user_id}") return CtxOutput(data=f"user_{user_id}")
register(local_context, "local_context") register(local_context, "local_context")
fn = get_function("local_context") fn = get_function("local_context")
@@ -509,7 +531,8 @@ class ContextTests(TestCase):
def test_context_invalid_value_raises(self): def test_context_invalid_value_raises(self):
"""Test that invalid context values raise ValueError.""" """Test that invalid context values raise ValueError."""
with self.assertRaises(ValueError) as cm: with self.assertRaises(ValueError) as cm:
@client(context='invalid')
@client(context="invalid")
def bad_context(request: HttpRequest) -> ValidOutput: def bad_context(request: HttpRequest) -> ValidOutput:
return ValidOutput(valid=True) return ValidOutput(valid=True)
@@ -522,17 +545,19 @@ class ContextTests(TestCase):
class Ctx1Output(BaseModel): class Ctx1Output(BaseModel):
value: int value: int
@client(context='global') @client(context="global")
def ctx1(request: HttpRequest) -> Ctx1Output: def ctx1(request: HttpRequest) -> Ctx1Output:
return Ctx1Output(value=1) return Ctx1Output(value=1)
register(ctx1, "ctx1") register(ctx1, "ctx1")
class Ctx2Output(BaseModel): class Ctx2Output(BaseModel):
value: int value: int
@client(context='local') @client(context="local")
def ctx2(request: HttpRequest, id: int) -> Ctx2Output: def ctx2(request: HttpRequest, id: int) -> Ctx2Output:
return Ctx2Output(value=id) return Ctx2Output(value=id)
register(ctx2, "ctx2") register(ctx2, "ctx2")
contexts = get_contexts() contexts = get_contexts()
@@ -568,7 +593,8 @@ class ChannelTests(TestCase):
def authorize(self, params=None): def authorize(self, params=None):
return True return True
from djarea.setup.registry import get_channel from mizan.setup.registry import get_channel
channel = get_channel("test-channel") channel = get_channel("test-channel")
self.assertEqual(channel, TestChannel) self.assertEqual(channel, TestChannel)
@@ -669,6 +695,7 @@ class TypeAnnotationTests(TestCase):
"""Test that missing return type raises TypeError.""" """Test that missing return type raises TypeError."""
with self.assertRaises(TypeError) as ctx: with self.assertRaises(TypeError) as ctx:
@client @client
def no_return(request: HttpRequest): def no_return(request: HttpRequest):
pass pass
@@ -681,21 +708,25 @@ class TypeAnnotationTests(TestCase):
@client @client
def return_int(request: HttpRequest, a: int, b: int) -> int: def return_int(request: HttpRequest, a: int, b: int) -> int:
return a + b return a + b
register(return_int, "return_int") register(return_int, "return_int")
@client @client
def return_str(request: HttpRequest, name: str) -> str: def return_str(request: HttpRequest, name: str) -> str:
return f"Hello, {name}!" return f"Hello, {name}!"
register(return_str, "return_str") register(return_str, "return_str")
@client @client
def return_dict(request: HttpRequest) -> dict: def return_dict(request: HttpRequest) -> dict:
return {"key": "value"} return {"key": "value"}
register(return_dict, "return_dict") register(return_dict, "return_dict")
@client @client
def return_list(request: HttpRequest) -> list: def return_list(request: HttpRequest) -> list:
return [1, 2, 3] return [1, 2, 3]
register(return_list, "return_list") register(return_list, "return_list")
# Verify all registered correctly # Verify all registered correctly
@@ -731,6 +762,7 @@ class TypeAnnotationTests(TestCase):
@client @client
def dict_input(request: HttpRequest, data: dict) -> GoodOutput: def dict_input(request: HttpRequest, data: dict) -> GoodOutput:
return GoodOutput(value=len(data)) return GoodOutput(value=len(data))
register(dict_input, "dict_input") register(dict_input, "dict_input")
fn = get_function("dict_input") fn = get_function("dict_input")
@@ -766,7 +798,7 @@ class TypeAnnotationTests(TestCase):
# In Python 3.10+, X | None creates a types.UnionType # In Python 3.10+, X | None creates a types.UnionType
self.assertTrue( self.assertTrue(
isinstance(fn.Output, types.UnionType) or fn.Output is UserProfile, 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 # Test execution returns data directly, not wrapped
@@ -802,11 +834,13 @@ class RPCModeTests(TestCase):
def tearDown(self): def tearDown(self):
# Reset settings cache # Reset settings cache
from djarea.setup.settings import clear_settings_cache from mizan.setup.settings import clear_settings_cache
clear_settings_cache() clear_settings_cache()
def test_client_decorator_with_websocket_true(self): def test_client_decorator_with_websocket_true(self):
"""Test @client(websocket=True) stores websocket=True in metadata.""" """Test @client(websocket=True) stores websocket=True in metadata."""
@client(websocket=True) @client(websocket=True)
def websocket_enabled(request) -> EchoOutput: def websocket_enabled(request) -> EchoOutput:
return EchoOutput(echo="ws") return EchoOutput(echo="ws")
@@ -815,6 +849,7 @@ class RPCModeTests(TestCase):
def test_client_decorator_without_websocket(self): def test_client_decorator_without_websocket(self):
"""Test @client without websocket parameter is HTTP-only (no websocket in meta).""" """Test @client without websocket parameter is HTTP-only (no websocket in meta)."""
@client @client
def http_only(request) -> EchoOutput: def http_only(request) -> EchoOutput:
return EchoOutput(echo="http") return EchoOutput(echo="http")
@@ -824,9 +859,11 @@ class RPCModeTests(TestCase):
def test_websocket_enabled_function_works_via_http(self): def test_websocket_enabled_function_works_via_http(self):
"""Test that @client(websocket=True) functions still work via HTTP.""" """Test that @client(websocket=True) functions still work via HTTP."""
@client(websocket=True) @client(websocket=True)
def ws_enabled(request) -> EchoOutput: def ws_enabled(request) -> EchoOutput:
return EchoOutput(echo="ws") return EchoOutput(echo="ws")
register(ws_enabled, "ws_enabled") register(ws_enabled, "ws_enabled")
request = self.factory.post("/") request = self.factory.post("/")
@@ -840,9 +877,11 @@ class RPCModeTests(TestCase):
def test_http_only_function_works_via_http(self): def test_http_only_function_works_via_http(self):
"""Test that @client functions (HTTP-only by default) work via HTTP.""" """Test that @client functions (HTTP-only by default) work via HTTP."""
@client @client
def http_only(request) -> EchoOutput: def http_only(request) -> EchoOutput:
return EchoOutput(echo="http") return EchoOutput(echo="http")
register(http_only, "http_only") register(http_only, "http_only")
request = self.factory.post("/") request = self.factory.post("/")
@@ -855,9 +894,11 @@ class RPCModeTests(TestCase):
def test_default_function_works_via_http(self): def test_default_function_works_via_http(self):
"""Test that @client functions without websocket param work via HTTP (default).""" """Test that @client functions without websocket param work via HTTP (default)."""
@client @client
def default_func(request) -> EchoOutput: def default_func(request) -> EchoOutput:
return EchoOutput(echo="default") return EchoOutput(echo="default")
register(default_func, "default_func") register(default_func, "default_func")
request = self.factory.post("/") request = self.factory.post("/")
@@ -870,12 +911,12 @@ class RPCModeTests(TestCase):
# ============================================================================= # =============================================================================
# DjareaFormMixin Tests # mizanFormMixin Tests
# ============================================================================= # =============================================================================
class DjareaFormMixinTests(TestCase): class mizanFormMixinTests(TestCase):
"""Tests for DjareaFormMixin and DjareaFormMeta.""" """Tests for mizanFormMixin and mizanFormMeta."""
def setUp(self): def setUp(self):
clear_registry() clear_registry()
@@ -890,12 +931,12 @@ class DjareaFormMixinTests(TestCase):
return request return request
def test_form_mixin_registration(self): 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 django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class TestForm(DjareaFormMixin, forms.Form): class TestForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="test_form") mizan = mizanFormMeta(name="test_form")
name = forms.CharField() name = forms.CharField()
# Verify functions were registered # Verify functions were registered
@@ -910,10 +951,10 @@ class DjareaFormMixinTests(TestCase):
def test_form_schema_function(self): def test_form_schema_function(self):
"""Test that schema function returns form field definitions.""" """Test that schema function returns form field definitions."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact_schema_test", name="contact_schema_test",
title="Contact Us", title="Contact Us",
submit_label="Send", submit_label="Send",
@@ -939,31 +980,35 @@ class DjareaFormMixinTests(TestCase):
def test_form_validate_function(self): def test_form_validate_function(self):
"""Test that validate function returns validation errors.""" """Test that validate function returns validation errors."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class ValidationForm(DjareaFormMixin, forms.Form): class ValidationForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="validation_test") mizan = mizanFormMeta(name="validation_test")
email = forms.EmailField() email = forms.EmailField()
request = self._make_request() request = self._make_request()
# Invalid email # 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.assertIsInstance(result, FunctionResult)
self.assertTrue(len(result.data["errors"]) > 0) self.assertTrue(len(result.data["errors"]) > 0)
# Valid email # 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.assertIsInstance(result, FunctionResult)
self.assertEqual(len(result.data["errors"]), 0) self.assertEqual(len(result.data["errors"]), 0)
def test_form_submit_function_success(self): def test_form_submit_function_success(self):
"""Test that submit function calls on_submit_success.""" """Test that submit function calls on_submit_success."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class SubmitForm(DjareaFormMixin, forms.Form): class SubmitForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="submit_test") mizan = mizanFormMeta(name="submit_test")
value = forms.CharField() value = forms.CharField()
def on_submit_success(self, request): def on_submit_success(self, request):
@@ -978,10 +1023,10 @@ class DjareaFormMixinTests(TestCase):
def test_form_submit_function_validation_failure(self): def test_form_submit_function_validation_failure(self):
"""Test that submit function returns errors on validation failure.""" """Test that submit function returns errors on validation failure."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class RequiredForm(DjareaFormMixin, forms.Form): class RequiredForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="required_test") mizan = mizanFormMeta(name="required_test")
required_field = forms.CharField() required_field = forms.CharField()
request = self._make_request() request = self._make_request()
@@ -993,10 +1038,10 @@ class DjareaFormMixinTests(TestCase):
self.assertIn("errors", result.data) self.assertIn("errors", result.data)
def test_form_meta_serialization(self): def test_form_meta_serialization(self):
"""Test that DjareaFormMeta serializes correctly (auth excluded).""" """Test that mizanFormMeta serializes correctly (auth excluded)."""
from djarea.forms import DjareaFormMeta from mizan.forms import mizanFormMeta
meta = DjareaFormMeta( meta = mizanFormMeta(
name="test", name="test",
title="Test Form", title="Test Form",
subtitle="A test form", subtitle="A test form",
@@ -1016,17 +1061,24 @@ class DjareaFormMixinTests(TestCase):
def test_form_with_custom_init_kwargs(self): def test_form_with_custom_init_kwargs(self):
"""Test that get_init_kwargs is called during form instantiation.""" """Test that get_init_kwargs is called during form instantiation."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class FormWithUser(DjareaFormMixin, forms.Form): class FormWithUser(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="init_kwargs_test") mizan = mizanFormMeta(name="init_kwargs_test")
user_email = forms.CharField() user_email = forms.CharField()
@classmethod @classmethod
def get_init_kwargs(cls, request): 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 from unittest.mock import MagicMock
user = MagicMock() user = MagicMock()
user.is_authenticated = True user.is_authenticated = True
user.email = "test@example.com" user.email = "test@example.com"
@@ -1043,10 +1095,10 @@ class DjareaFormMixinTests(TestCase):
def test_formset_functions_not_registered_by_default(self): def test_formset_functions_not_registered_by_default(self):
"""Test that formset functions are not registered by default.""" """Test that formset functions are not registered by default."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class NoFormsetForm(DjareaFormMixin, forms.Form): class NoFormsetForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="no_formset_test") mizan = mizanFormMeta(name="no_formset_test")
field = forms.CharField() field = forms.CharField()
# Formset functions should not exist # Formset functions should not exist
@@ -1057,10 +1109,10 @@ class DjareaFormMixinTests(TestCase):
def test_formset_functions_registered_when_enabled(self): def test_formset_functions_registered_when_enabled(self):
"""Test that formset functions are registered when enable_formset=True.""" """Test that formset functions are registered when enable_formset=True."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
class WithFormsetForm(DjareaFormMixin, forms.Form): class WithFormsetForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta(name="with_formset_test", enable_formset=True) mizan = mizanFormMeta(name="with_formset_test", enable_formset=True)
field = forms.CharField() field = forms.CharField()
# Formset functions should exist # 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.validate"))
self.assertIsNotNone(get_function("with_formset_test.formset.submit")) self.assertIsNotNone(get_function("with_formset_test.formset.submit"))
def test_form_without_djarea_not_registered(self): def test_form_without_mizan_not_registered(self):
"""Test that forms without djarea attribute are not registered.""" """Test that forms without mizan attribute are not registered."""
from django import forms from django import forms
from djarea.forms import DjareaFormMixin from mizan.forms import mizanFormMixin
class PlainForm(DjareaFormMixin, forms.Form): class PlainForm(mizanFormMixin, forms.Form):
# No djarea attribute # No mizan attribute
field = forms.CharField() field = forms.CharField()
# Should not be registered # Should not be registered

View File

@@ -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 These tests simulate a professional security researcher attempting to break
the protocol. Focus areas: the protocol. Focus areas:
@@ -36,14 +36,14 @@ from django.http import HttpRequest
from django.test import RequestFactory, TestCase, override_settings from django.test import RequestFactory, TestCase, override_settings
from pydantic import BaseModel, field_validator, model_validator from pydantic import BaseModel, field_validator, model_validator
from djarea.client.executor import ( from mizan.client.executor import (
ErrorCode, ErrorCode,
FunctionError, FunctionError,
FunctionResult, FunctionResult,
execute_function, execute_function,
) )
from djarea.setup.registry import clear_registry, get_function, register from mizan.setup.registry import clear_registry, get_function, register
from djarea.client import ServerFunction, client from mizan.client import ServerFunction, client
# ============================================================================= # =============================================================================
@@ -86,16 +86,19 @@ class MemoryExhaustionTests(TestCase):
@client @client
def process_data(request: HttpRequest, data: dict) -> SimpleOutput: def process_data(request: HttpRequest, data: dict) -> SimpleOutput:
return SimpleOutput(value=str(len(str(data)))) return SimpleOutput(value=str(len(str(data))))
register(process_data, "process_data") register(process_data, "process_data")
@client @client
def process_string(request: HttpRequest, text: str) -> SimpleOutput: def process_string(request: HttpRequest, text: str) -> SimpleOutput:
return SimpleOutput(value=f"len={len(text)}") return SimpleOutput(value=f"len={len(text)}")
register(process_string, "process_string") register(process_string, "process_string")
@client @client
def process_list(request: HttpRequest, items: list) -> SimpleOutput: def process_list(request: HttpRequest, items: list) -> SimpleOutput:
return SimpleOutput(value=str(len(items))) return SimpleOutput(value=str(len(items)))
register(process_list, "process_list") register(process_list, "process_list")
def tearDown(self): def tearDown(self):
@@ -141,7 +144,9 @@ class MemoryExhaustionTests(TestCase):
def create_wide_nested(depth, width): def create_wide_nested(depth, width):
if depth == 0: if depth == 0:
return "leaf" 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 # 5 levels deep, 10 wide = 10^5 = 100,000 nodes
wide_structure = create_wide_nested(5, 10) wide_structure = create_wide_nested(5, 10)
@@ -225,16 +230,19 @@ class TypeConfusionTests(TestCase):
@client @client
def numeric_func(request: HttpRequest, value: float) -> NumericOutput: def numeric_func(request: HttpRequest, value: float) -> NumericOutput:
return NumericOutput(result=value * 2) return NumericOutput(result=value * 2)
register(numeric_func, "numeric_func") register(numeric_func, "numeric_func")
@client @client
def any_input(request: HttpRequest, data: Any) -> SimpleOutput: def any_input(request: HttpRequest, data: Any) -> SimpleOutput:
return SimpleOutput(value=str(type(data).__name__)) return SimpleOutput(value=str(type(data).__name__))
register(any_input, "any_input") register(any_input, "any_input")
@client @client
def bool_func(request: HttpRequest, flag: bool) -> SimpleOutput: def bool_func(request: HttpRequest, flag: bool) -> SimpleOutput:
return SimpleOutput(value="yes" if flag else "no") return SimpleOutput(value="yes" if flag else "no")
register(bool_func, "bool_func") register(bool_func, "bool_func")
def tearDown(self): def tearDown(self):
@@ -254,8 +262,9 @@ class TypeConfusionTests(TestCase):
request = self._make_request() request = self._make_request()
import math import math
# JSON doesn't support NaN directly, but we test the boundary # 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 # numeric_func doubles the value; NaN * 2 is still NaN
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
@@ -267,21 +276,21 @@ class TypeConfusionTests(TestCase):
""" """
request = self._make_request() 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 # inf * 2 is still inf
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
self.assertEqual(result.data["result"], float('inf')) self.assertEqual(result.data["result"], float("inf"))
def test_negative_infinity_handling(self): def test_negative_infinity_handling(self):
"""Test handling of negative infinity.""" """Test handling of negative infinity."""
request = self._make_request() 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 # -inf * 2 is still -inf
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
self.assertEqual(result.data["result"], float('-inf')) self.assertEqual(result.data["result"], float("-inf"))
def test_very_small_float(self): def test_very_small_float(self):
"""Test handling of very small floats (denormalized).""" """Test handling of very small floats (denormalized)."""
@@ -304,7 +313,7 @@ class TypeConfusionTests(TestCase):
result = execute_function(request, "numeric_func", {"value": huge}) result = execute_function(request, "numeric_func", {"value": huge})
# Doubling max float should overflow to inf # Doubling max float should overflow to inf
if isinstance(result, FunctionResult): if isinstance(result, FunctionResult):
self.assertEqual(result.data["result"], float('inf')) self.assertEqual(result.data["result"], float("inf"))
def test_boolean_type_confusion(self): def test_boolean_type_confusion(self):
""" """
@@ -319,7 +328,7 @@ class TypeConfusionTests(TestCase):
(True, "yes"), (True, "yes"),
(False, "no"), (False, "no"),
(1, "yes"), # int 1 -> bool True (1, "yes"), # int 1 -> bool True
(0, "no"), # int 0 -> bool False (0, "no"), # int 0 -> bool False
("true", "yes"), # string coercion ("true", "yes"), # string coercion
("false", "no"), ("false", "no"),
] ]
@@ -401,12 +410,14 @@ class RaceConditionTests(TestCase):
test_instance.executions.append(exec_time) test_instance.executions.append(exec_time)
return TimingOutput(authenticated=is_auth, timestamp=exec_time) return TimingOutput(authenticated=is_auth, timestamp=exec_time)
register(timed_auth_func, "timed_auth_func") register(timed_auth_func, "timed_auth_func")
@client @client
def counter_func(request: HttpRequest) -> SimpleOutput: def counter_func(request: HttpRequest) -> SimpleOutput:
test_instance.call_count += 1 test_instance.call_count += 1
return SimpleOutput(value=str(test_instance.call_count)) return SimpleOutput(value=str(test_instance.call_count))
register(counter_func, "counter_func") register(counter_func, "counter_func")
def tearDown(self): def tearDown(self):
@@ -454,6 +465,7 @@ class RaceConditionTests(TestCase):
Simulates checking if the user authentication state could change Simulates checking if the user authentication state could change
between validation and execution. between validation and execution.
""" """
# Create a user mock that changes state # Create a user mock that changes state
class MutableUser: class MutableUser:
def __init__(self): def __init__(self):
@@ -510,6 +522,7 @@ class PydanticBypassTests(TestCase):
@client @client
def typed_func(request: HttpRequest, count: int, name: str) -> SimpleOutput: def typed_func(request: HttpRequest, count: int, name: str) -> SimpleOutput:
return SimpleOutput(value=f"{name}:{count}") return SimpleOutput(value=f"{name}:{count}")
register(typed_func, "typed_func") register(typed_func, "typed_func")
@client @client
@@ -518,6 +531,7 @@ class PydanticBypassTests(TestCase):
if "@" not in email: if "@" not in email:
raise ValueError("Invalid email format") raise ValueError("Invalid email format")
return SimpleOutput(value=email) return SimpleOutput(value=email)
register(strict_func, "strict_func") register(strict_func, "strict_func")
def tearDown(self): def tearDown(self):
@@ -537,7 +551,9 @@ class PydanticBypassTests(TestCase):
self.assertIsInstance(result, FunctionResult) self.assertIsInstance(result, FunctionResult)
# Invalid type - dict for int # 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.assertIsInstance(result, FunctionError)
self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR) self.assertEqual(result.code, ErrorCode.VALIDATION_ERROR)
@@ -606,13 +622,14 @@ class WebSocketProtocolTests(TestCase):
@client @client
def ws_func(request: HttpRequest, data: str) -> SimpleOutput: def ws_func(request: HttpRequest, data: str) -> SimpleOutput:
return SimpleOutput(value=data) return SimpleOutput(value=data)
register(ws_func, "ws_func") register(ws_func, "ws_func")
def tearDown(self): def tearDown(self):
clear_registry() clear_registry()
def _create_consumer(self, user=None): def _create_consumer(self, user=None):
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
consumer.scope = {"user": user or AnonymousUser()} consumer.scope = {"user": user or AnonymousUser()}
@@ -680,7 +697,7 @@ class WebSocketProtocolTests(TestCase):
"action": "rpc", "action": "rpc",
"id": mal_id, "id": mal_id,
"fn": "ws_func", "fn": "ws_func",
"args": {"data": "test"} "args": {"data": "test"},
} }
async_to_sync(consumer.receive_json)(payload) async_to_sync(consumer.receive_json)(payload)
@@ -693,8 +710,8 @@ class WebSocketProtocolTests(TestCase):
Try rapid subscribe/unsubscribe cycles and malformed params. Try rapid subscribe/unsubscribe cycles and malformed params.
""" """
from djarea.channels import register as register_channel, ReactChannel from mizan.channels import register as register_channel, ReactChannel
from djarea.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
channels_registry.clear() channels_registry.clear()
@@ -718,14 +735,12 @@ class WebSocketProtocolTests(TestCase):
# Rapid subscribe/unsubscribe # Rapid subscribe/unsubscribe
for i in range(50): for i in range(50):
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "test-channel", {"channel": "test-channel", "params": {"room": f"room_{i}"}}
"params": {"room": f"room_{i}"} )
}) async_to_sync(consumer._handle_unsubscribe)(
async_to_sync(consumer._handle_unsubscribe)({ {"channel": "test-channel", "params": {"room": f"room_{i}"}}
"channel": "test-channel", )
"params": {"room": f"room_{i}"}
})
# Should not have any lingering subscriptions # Should not have any lingering subscriptions
self.assertEqual(len(consumer._subscriptions), 0) self.assertEqual(len(consumer._subscriptions), 0)
@@ -736,8 +751,8 @@ class WebSocketProtocolTests(TestCase):
""" """
Test attempting to subscribe to the same channel twice. Test attempting to subscribe to the same channel twice.
""" """
from djarea.channels import register as register_channel, ReactChannel from mizan.channels import register as register_channel, ReactChannel
from djarea.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
channels_registry.clear() channels_registry.clear()
@@ -757,17 +772,15 @@ class WebSocketProtocolTests(TestCase):
consumer, messages = self._create_consumer() consumer, messages = self._create_consumer()
# First subscription # First subscription
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "dup-channel", {"channel": "dup-channel", "params": {}}
"params": {} )
})
self.assertIn("subscribed", messages[-1]) self.assertIn("subscribed", messages[-1])
# Second subscription to same channel # Second subscription to same channel
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "dup-channel", {"channel": "dup-channel", "params": {}}
"params": {} )
})
# Should return error about already subscribed # Should return error about already subscribed
self.assertIn("error", messages[-1]) self.assertIn("error", messages[-1])
@@ -797,6 +810,7 @@ class TimingSideChannelTests(TestCase):
@client @client
def existing_func(request: HttpRequest) -> SimpleOutput: def existing_func(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="exists") return SimpleOutput(value="exists")
register(existing_func, "existing_func") register(existing_func, "existing_func")
@client @client
@@ -804,6 +818,7 @@ class TimingSideChannelTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Auth required") raise PermissionError("Auth required")
return SimpleOutput(value="authenticated") return SimpleOutput(value="authenticated")
register(auth_func, "auth_func") register(auth_func, "auth_func")
def tearDown(self): def tearDown(self):
@@ -910,6 +925,7 @@ class UnicodeNormalizationTests(TestCase):
if username == "admin": if username == "admin":
raise PermissionError("Reserved username") raise PermissionError("Reserved username")
return SimpleOutput(value=f"Hello, {username}") return SimpleOutput(value=f"Hello, {username}")
register(username_func, "username_func") register(username_func, "username_func")
def tearDown(self): def tearDown(self):
@@ -1011,6 +1027,7 @@ class JSONParsingEdgeCaseTests(TestCase):
@client @client
def json_func(request: HttpRequest, data: dict) -> SimpleOutput: def json_func(request: HttpRequest, data: dict) -> SimpleOutput:
return SimpleOutput(value=json.dumps(data)) return SimpleOutput(value=json.dumps(data))
register(json_func, "json_func") register(json_func, "json_func")
def tearDown(self): def tearDown(self):
@@ -1063,7 +1080,7 @@ class JSONParsingEdgeCaseTests(TestCase):
edge_numbers = { edge_numbers = {
"max_safe_int": 9007199254740991, # 2^53 - 1 "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, "huge_int": 10**100,
"tiny_float": 1e-308, "tiny_float": 1e-308,
"huge_float": 1e308, "huge_float": 1e308,
@@ -1097,6 +1114,7 @@ class AuthorizationBoundaryTests(TestCase):
if target_role not in allowed_roles: if target_role not in allowed_roles:
raise PermissionError(f"Cannot escalate to {target_role}") raise PermissionError(f"Cannot escalate to {target_role}")
return SimpleOutput(value=f"Role set to {target_role}") return SimpleOutput(value=f"Role set to {target_role}")
register(escalation_func, "escalation_func") register(escalation_func, "escalation_func")
def tearDown(self): def tearDown(self):
@@ -1160,8 +1178,8 @@ class RegistrationSecurityTests(TestCase):
Note: Re-registration of the same function name IS allowed for hot reload. Note: Re-registration of the same function name IS allowed for hot reload.
But a DIFFERENT function cannot take over an existing name. But a DIFFERENT function cannot take over an existing name.
""" """
from djarea.client import ServerFunction from mizan.client import ServerFunction
from djarea.setup.registry import register from mizan.setup.registry import register
# Register first function # Register first function
class OriginalFunc(ServerFunction): class OriginalFunc(ServerFunction):
@@ -1196,6 +1214,7 @@ class RegistrationSecurityTests(TestCase):
@client @client
def normal_func_name(request: HttpRequest) -> SimpleOutput: def normal_func_name(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="ok") return SimpleOutput(value="ok")
register(normal_func_name, "normal_func_name") register(normal_func_name, "normal_func_name")
fn = get_function("normal_func_name") fn = get_function("normal_func_name")

View File

@@ -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 These tests probe for potential vulnerabilities without running any
malicious code - they simply verify that defenses work correctly. 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 django.test import RequestFactory, TestCase, Client, override_settings
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from djarea.client.executor import ( from mizan.client.executor import (
ErrorCode, ErrorCode,
FunctionError, FunctionError,
FunctionResult, FunctionResult,
execute_function, execute_function,
function_call_view, function_call_view,
) )
from djarea.setup.registry import clear_registry, register, register_as, get_function from mizan.setup.registry import clear_registry, register, register_as, get_function
from djarea.client import ServerFunction, client from mizan.client import ServerFunction, client
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
User = get_user_model() User = get_user_model()
@@ -90,6 +90,7 @@ class InputValidationSecurityTests(TestCase):
@client @client
def echo_any(request: HttpRequest, message: str) -> SimpleOutput: def echo_any(request: HttpRequest, message: str) -> SimpleOutput:
return SimpleOutput(value=message) return SimpleOutput(value=message)
register(echo_any, "echo_any") register(echo_any, "echo_any")
@client @client
@@ -97,16 +98,18 @@ class InputValidationSecurityTests(TestCase):
def count_depth(obj, depth=0): def count_depth(obj, depth=0):
if isinstance(obj, dict): if isinstance(obj, dict):
return max( return max(
(count_depth(v, depth + 1) for v in obj.values()), (count_depth(v, depth + 1) for v in obj.values()), default=depth
default=depth
) )
return depth return depth
return DeeplyNestedOutput(depth=count_depth(data)) return DeeplyNestedOutput(depth=count_depth(data))
register(process_nested, "process_nested") register(process_nested, "process_nested")
@client @client
def typed_input(request: HttpRequest, age: int, name: str) -> SimpleOutput: def typed_input(request: HttpRequest, age: int, name: str) -> SimpleOutput:
return SimpleOutput(value=f"{name}:{age}") return SimpleOutput(value=f"{name}:{age}")
register(typed_input, "typed_input") register(typed_input, "typed_input")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -184,8 +187,7 @@ class InputValidationSecurityTests(TestCase):
# Try to bypass integer validation with string # Try to bypass integer validation with string
result = execute_function( result = execute_function(
request, "typed_input", request, "typed_input", {"age": "25; DROP TABLE users", "name": "test"}
{"age": "25; DROP TABLE users", "name": "test"}
) )
# Pydantic should coerce "25; DROP TABLE users" and fail # Pydantic should coerce "25; DROP TABLE users" and fail
# because it's not a valid integer # because it's not a valid integer
@@ -208,8 +210,9 @@ class InputValidationSecurityTests(TestCase):
request = self._make_request() request = self._make_request()
result = execute_function( result = execute_function(
request, "echo_any", request,
{"message": "test", "__proto__": "polluted", "extra": "ignored"} "echo_any",
{"message": "test", "__proto__": "polluted", "extra": "ignored"},
) )
# Should succeed, extra fields ignored # Should succeed, extra fields ignored
@@ -246,6 +249,7 @@ class AuthorizationSecurityTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Authentication required") raise PermissionError("Authentication required")
return SensitiveOutput(secret="sensitive", user_id=request.user.id) return SensitiveOutput(secret="sensitive", user_id=request.user.id)
register(requires_auth, "requires_auth") register(requires_auth, "requires_auth")
@client @client
@@ -255,6 +259,7 @@ class AuthorizationSecurityTests(TestCase):
if not request.user.is_staff: if not request.user.is_staff:
raise PermissionError("Admin access required") raise PermissionError("Admin access required")
return AdminOnlyOutput(admin_data="secret admin data") return AdminOnlyOutput(admin_data="secret admin data")
register(requires_admin, "requires_admin") register(requires_admin, "requires_admin")
@client @client
@@ -264,6 +269,7 @@ class AuthorizationSecurityTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("User not logged in") raise PermissionError("User not logged in")
return SimpleOutput(value="ok") return SimpleOutput(value="ok")
register(leaky_auth_check, "leaky_auth_check") register(leaky_auth_check, "leaky_auth_check")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -316,6 +322,7 @@ class AuthorizationSecurityTests(TestCase):
def test_spoofed_is_authenticated_attribute(self): def test_spoofed_is_authenticated_attribute(self):
"""Test that spoofing is_authenticated doesn't work.""" """Test that spoofing is_authenticated doesn't work."""
# Create object that claims to be authenticated but isn't a real user # Create object that claims to be authenticated but isn't a real user
class FakeUser: class FakeUser:
is_authenticated = True is_authenticated = True
@@ -330,6 +337,7 @@ class AuthorizationSecurityTests(TestCase):
def test_user_id_manipulation_blocked(self): def test_user_id_manipulation_blocked(self):
"""Test that user can't access other users' data via input.""" """Test that user can't access other users' data via input."""
@client @client
def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput: def get_user_data(request: HttpRequest, target_user_id: int) -> SensitiveOutput:
# Properly checking: can only access own data # Properly checking: can only access own data
@@ -338,6 +346,7 @@ class AuthorizationSecurityTests(TestCase):
if request.user.id != target_user_id: if request.user.id != target_user_id:
raise PermissionError("Cannot access other users' data") raise PermissionError("Cannot access other users' data")
return SensitiveOutput(secret="data", user_id=target_user_id) return SensitiveOutput(secret="data", user_id=target_user_id)
register(get_user_data, "get_user_data") register(get_user_data, "get_user_data")
user = MagicMock() user = MagicMock()
@@ -380,11 +389,12 @@ class HTTPEndpointSecurityTests(TestCase):
@client @client
def public_echo(request: HttpRequest, message: str) -> SimpleOutput: def public_echo(request: HttpRequest, message: str) -> SimpleOutput:
return SimpleOutput(value=message) return SimpleOutput(value=message)
register(public_echo, "public_echo") register(public_echo, "public_echo")
def test_get_method_rejected(self): def test_get_method_rejected(self):
"""Test that GET requests are rejected.""" """Test that GET requests are rejected."""
request = self.factory.get("/api/djarea/call/") request = self.factory.get("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
response = function_call_view(request) response = function_call_view(request)
@@ -396,7 +406,7 @@ class HTTPEndpointSecurityTests(TestCase):
def test_put_method_rejected(self): def test_put_method_rejected(self):
"""Test that PUT requests are rejected.""" """Test that PUT requests are rejected."""
request = self.factory.put("/api/djarea/call/") request = self.factory.put("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check 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): def test_delete_method_rejected(self):
"""Test that DELETE requests are rejected.""" """Test that DELETE requests are rejected."""
request = self.factory.delete("/api/djarea/call/") request = self.factory.delete("/api/mizan/call/")
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True # Bypass CSRF to test method check 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): def test_invalid_json_rejected(self):
"""Test that invalid JSON is rejected gracefully.""" """Test that invalid JSON is rejected gracefully."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/", data="{invalid json", content_type="application/json"
data="{invalid json",
content_type="application/json"
) )
request.user = AnonymousUser() request.user = AnonymousUser()
# Bypass CSRF for this test # Bypass CSRF for this test
@@ -435,9 +443,7 @@ class HTTPEndpointSecurityTests(TestCase):
def test_empty_body_rejected(self): def test_empty_body_rejected(self):
"""Test that empty body is rejected (fn field required).""" """Test that empty body is rejected (fn field required)."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/", data="", content_type="application/json"
data="",
content_type="application/json"
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -450,9 +456,9 @@ class HTTPEndpointSecurityTests(TestCase):
def test_missing_fn_field_rejected(self): def test_missing_fn_field_rejected(self):
"""Test that request without fn field is rejected.""" """Test that request without fn field is rejected."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data='{"args": {"message": "test"}}', data='{"args": {"message": "test"}}',
content_type="application/json" content_type="application/json",
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -467,9 +473,9 @@ class HTTPEndpointSecurityTests(TestCase):
def test_content_type_not_enforced(self): def test_content_type_not_enforced(self):
"""Test behavior with wrong content type.""" """Test behavior with wrong content type."""
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data='{"fn": "public_echo", "args": {"message": "test"}}', data='{"fn": "public_echo", "args": {"message": "test"}}',
content_type="text/plain" content_type="text/plain",
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -491,9 +497,9 @@ class HTTPEndpointSecurityTests(TestCase):
for name in malicious_names: for name in malicious_names:
request = self.factory.post( request = self.factory.post(
"/api/djarea/call/", "/api/mizan/call/",
data=json.dumps({"fn": name, "args": {}}), data=json.dumps({"fn": name, "args": {}}),
content_type="application/json" content_type="application/json",
) )
request.user = AnonymousUser() request.user = AnonymousUser()
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
@@ -528,6 +534,7 @@ class WebSocketRPCSecurityTests(TestCase):
@client(websocket=True) @client(websocket=True)
def ws_echo(request: HttpRequest, message: str) -> SimpleOutput: def ws_echo(request: HttpRequest, message: str) -> SimpleOutput:
return SimpleOutput(value=message) return SimpleOutput(value=message)
register(ws_echo, "ws_echo") register(ws_echo, "ws_echo")
@client(websocket=True) @client(websocket=True)
@@ -535,11 +542,12 @@ class WebSocketRPCSecurityTests(TestCase):
if not request.user.is_authenticated: if not request.user.is_authenticated:
raise PermissionError("Auth required") raise PermissionError("Auth required")
return SensitiveOutput(secret="data", user_id=request.user.id) return SensitiveOutput(secret="data", user_id=request.user.id)
register(ws_auth_required, "ws_auth_required") register(ws_auth_required, "ws_auth_required")
def test_rpc_without_id_field(self): def test_rpc_without_id_field(self):
"""Test RPC call without required id field.""" """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 from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -552,7 +560,9 @@ class WebSocketRPCSecurityTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Call without id # 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 # Should return error about missing id
self.assertEqual(len(sent_messages), 1) self.assertEqual(len(sent_messages), 1)
@@ -560,7 +570,7 @@ class WebSocketRPCSecurityTests(TestCase):
def test_rpc_without_fn_field(self): def test_rpc_without_fn_field(self):
"""Test RPC call without function name.""" """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 from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -581,7 +591,7 @@ class WebSocketRPCSecurityTests(TestCase):
def test_rpc_nonexistent_function(self): def test_rpc_nonexistent_function(self):
"""Test RPC call to non-existent function.""" """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 from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -592,18 +602,16 @@ class WebSocketRPCSecurityTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
async_to_sync(consumer._handle_rpc)({ async_to_sync(consumer._handle_rpc)(
"id": "123", {"id": "123", "fn": "nonexistent_function", "args": {}}
"fn": "nonexistent_function", )
"args": {}
})
self.assertEqual(sent_messages[0]["ok"], False) self.assertEqual(sent_messages[0]["ok"], False)
self.assertEqual(sent_messages[0]["error"]["code"], "NOT_FOUND") self.assertEqual(sent_messages[0]["error"]["code"], "NOT_FOUND")
def test_rpc_validation_error_returned(self): def test_rpc_validation_error_returned(self):
"""Test that validation errors are returned properly over RPC.""" """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 from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -615,20 +623,20 @@ class WebSocketRPCSecurityTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Call with wrong input type # Call with wrong input type
async_to_sync(consumer._handle_rpc)({ async_to_sync(consumer._handle_rpc)(
"id": "123", {
"fn": "ws_echo", "id": "123",
"args": {"message": 12345} # Should be string "fn": "ws_echo",
}) "args": {"message": 12345}, # Should be string
}
)
# Pydantic coerces int to string, so this actually succeeds # Pydantic coerces int to string, so this actually succeeds
# Let's test with missing required field instead # Let's test with missing required field instead
sent_messages.clear() sent_messages.clear()
async_to_sync(consumer._handle_rpc)({ async_to_sync(consumer._handle_rpc)(
"id": "124", {"id": "124", "fn": "ws_echo", "args": {}} # Missing message
"fn": "ws_echo", )
"args": {} # Missing message
})
self.assertEqual(sent_messages[0]["ok"], False) self.assertEqual(sent_messages[0]["ok"], False)
self.assertEqual(sent_messages[0]["error"]["code"], "VALIDATION_ERROR") 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 # Simulate accessing sensitive config that might leak in error
secret_key = "super_secret_key_12345" secret_key = "super_secret_key_12345"
raise RuntimeError(f"Database error with key: {secret_key}") raise RuntimeError(f"Database error with key: {secret_key}")
register(error_with_sensitive_data, "error_with_sensitive_data") register(error_with_sensitive_data, "error_with_sensitive_data")
@client @client
def working_function(request: HttpRequest) -> SimpleOutput: def working_function(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="works") return SimpleOutput(value="works")
register(working_function, "working_function") register(working_function, "working_function")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -712,9 +722,11 @@ class InformationDisclosureTests(TestCase):
def test_validation_errors_dont_leak_internals(self): def test_validation_errors_dont_leak_internals(self):
"""Test that validation errors only show field-level info.""" """Test that validation errors only show field-level info."""
@client @client
def validated_func(request: HttpRequest, secret_field: str) -> SimpleOutput: def validated_func(request: HttpRequest, secret_field: str) -> SimpleOutput:
return SimpleOutput(value=secret_field) return SimpleOutput(value=secret_field)
register(validated_func, "validated_func") register(validated_func, "validated_func")
request = self._make_request() request = self._make_request()
@@ -758,11 +770,13 @@ class InjectionPreventionTests(TestCase):
def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput: def echo_safe(request: HttpRequest, user_input: str) -> SimpleOutput:
# This function just echoes - the test is about validation # This function just echoes - the test is about validation
return SimpleOutput(value=user_input) return SimpleOutput(value=user_input)
register(echo_safe, "echo_safe") register(echo_safe, "echo_safe")
@client @client
def process_dict(request: HttpRequest, data: dict) -> SimpleOutput: def process_dict(request: HttpRequest, data: dict) -> SimpleOutput:
return SimpleOutput(value=str(len(data))) return SimpleOutput(value=str(len(data)))
register(process_dict, "process_dict") register(process_dict, "process_dict")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -847,8 +861,7 @@ class InjectionPreventionTests(TestCase):
request = self._make_request() request = self._make_request()
result = execute_function( result = execute_function(
request, "process_dict", request, "process_dict", {"data": {"__proto__": {"admin": True}}}
{"data": {"__proto__": {"admin": True}}}
) )
# Should succeed - it's just a dict with a key named "__proto__" # Should succeed - it's just a dict with a key named "__proto__"
@@ -870,18 +883,20 @@ class ChannelAuthorizationTests(TestCase):
def setUp(self): def setUp(self):
clear_registry() clear_registry()
# Also clear the channels 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() channels_registry.clear()
self._register_test_channels() self._register_test_channels()
def tearDown(self): def tearDown(self):
clear_registry() clear_registry()
from djarea.channels import _registry as channels_registry from mizan.channels import _registry as channels_registry
channels_registry.clear() channels_registry.clear()
def _register_test_channels(self): def _register_test_channels(self):
"""Register test channels using the channels module's register.""" """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 PublicChannel(ReactChannel):
class DjangoMessage(BaseModel): class DjangoMessage(BaseModel):
@@ -923,8 +938,8 @@ class ChannelAuthorizationTests(TestCase):
def test_authorize_exception_handling(self): def test_authorize_exception_handling(self):
"""Test that exceptions in authorize() are handled safely.""" """Test that exceptions in authorize() are handled safely."""
from djarea.channels import register as register_channel, ReactChannel from mizan.channels import register as register_channel, ReactChannel
from djarea.channels.connection import DjangoReactConsumer from mizan.channels.connection import DjangoReactConsumer
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
class ErrorChannel(ReactChannel): class ErrorChannel(ReactChannel):
@@ -947,10 +962,9 @@ class ChannelAuthorizationTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "error-channel", {"channel": "error-channel", "params": {}}
"params": {} )
})
# Should return error, not crash # Should return error, not crash
self.assertEqual(len(sent_messages), 1) self.assertEqual(len(sent_messages), 1)
@@ -958,7 +972,7 @@ class ChannelAuthorizationTests(TestCase):
def test_authorize_false_blocks_subscription(self): def test_authorize_false_blocks_subscription(self):
"""Test that returning False from authorize blocks subscription.""" """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 from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -969,10 +983,9 @@ class ChannelAuthorizationTests(TestCase):
sent_messages = [] sent_messages = []
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "auth-channel", {"channel": "auth-channel", "params": {}}
"params": {} )
})
# Should be rejected # Should be rejected
self.assertIn("error", sent_messages[0]) self.assertIn("error", sent_messages[0])
@@ -980,7 +993,7 @@ class ChannelAuthorizationTests(TestCase):
def test_param_validation_before_authorize(self): def test_param_validation_before_authorize(self):
"""Test that params are validated before authorize is called.""" """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 from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -992,17 +1005,16 @@ class ChannelAuthorizationTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Invalid params (string instead of int) # Invalid params (string instead of int)
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "room-channel", {"channel": "room-channel", "params": {"room_id": "not_an_int"}}
"params": {"room_id": "not_an_int"} )
})
# Should fail validation # Should fail validation
self.assertIn("error", sent_messages[0]) self.assertIn("error", sent_messages[0])
def test_room_authorization_enforced(self): def test_room_authorization_enforced(self):
"""Test that room-level authorization is enforced.""" """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 from asgiref.sync import async_to_sync
consumer = DjangoReactConsumer() consumer = DjangoReactConsumer()
@@ -1015,17 +1027,15 @@ class ChannelAuthorizationTests(TestCase):
consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x)) consumer.send_json = AsyncMock(side_effect=lambda x: sent_messages.append(x))
# Room 1 - allowed # Room 1 - allowed
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "room-channel", {"channel": "room-channel", "params": {"room_id": 1}}
"params": {"room_id": 1} )
})
self.assertIn("subscribed", sent_messages[-1]) self.assertIn("subscribed", sent_messages[-1])
# Room 999 - not allowed # Room 999 - not allowed
async_to_sync(consumer._handle_subscribe)({ async_to_sync(consumer._handle_subscribe)(
"channel": "room-channel", {"channel": "room-channel", "params": {"room_id": 999}}
"params": {"room_id": 999} )
})
self.assertIn("error", sent_messages[-1]) self.assertIn("error", sent_messages[-1])
@@ -1057,6 +1067,7 @@ class AbusePreventionTests(TestCase):
@client @client
def simple_func(request: HttpRequest) -> SimpleOutput: def simple_func(request: HttpRequest) -> SimpleOutput:
return SimpleOutput(value="ok") return SimpleOutput(value="ok")
register(simple_func, "simple_func") register(simple_func, "simple_func")
def _make_request(self, user=None): def _make_request(self, user=None):
@@ -1081,9 +1092,11 @@ class AbusePreventionTests(TestCase):
def test_large_batch_execution(self): def test_large_batch_execution(self):
"""Test handling of large batch of different inputs.""" """Test handling of large batch of different inputs."""
@client @client
def batch_func(request: HttpRequest, idx: int) -> SimpleOutput: def batch_func(request: HttpRequest, idx: int) -> SimpleOutput:
return SimpleOutput(value=f"item_{idx}") return SimpleOutput(value=f"item_{idx}")
register(batch_func, "batch_func") register(batch_func, "batch_func")
request = self._make_request() request = self._make_request()

View File

@@ -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), Models: Publisher Author Book Chapter Section (5 levels deep),
two FKs to same model, slug PK, UUID PK, self-referential FK, M2M, 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 django.test import TestCase
from djarea.shapes import Shape, Diff, NestedDiff from mizan.shapes import Shape, Diff, NestedDiff
import uuid import uuid
from tests.models import ( 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]): class BookWithEditorShape(Shape[Book]):
"""Two FKs to the same model (author + editor).""" """Two FKs to the same model (author + editor)."""
id: int | None = None id: int | None = None
title: str title: str
author: FlatAuthorShape author: FlatAuthorShape
@@ -117,7 +124,6 @@ class CategoryShape(Shape[Category]):
class TestShapeClassCreation(TestCase): class TestShapeClassCreation(TestCase):
def test_flat_shape_has_no_nested(self): def test_flat_shape_has_no_nested(self):
self.assertEqual(FlatAuthorShape._nested, {}) self.assertEqual(FlatAuthorShape._nested, {})
self.assertEqual(FlatAuthorShape._field_names, ["id", "name"]) self.assertEqual(FlatAuthorShape._field_names, ["id", "name"])
@@ -171,7 +177,9 @@ class TestShapeClassCreation(TestCase):
self.assertIs(CategoryShape._nested["children"], CategoryShape) self.assertIs(CategoryShape._nested["children"], CategoryShape)
def test_multiple_shapes_same_model_independent(self): 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) self.assertNotEqual(FlatBookShape._spec, BookDetailShape._spec)
@@ -181,7 +189,6 @@ class TestShapeClassCreation(TestCase):
class TestShapeQuery(TestCase): class TestShapeQuery(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.publisher = Publisher.objects.create(name="Orbit", country="UK") cls.publisher = Publisher.objects.create(name="Orbit", country="UK")
@@ -189,8 +196,10 @@ class TestShapeQuery(TestCase):
name="Ursula", bio="Legend", publisher=cls.publisher name="Ursula", bio="Legend", publisher=cls.publisher
) )
cls.author = Author.objects.create( cls.author = Author.objects.create(
name="Ann Leckie", bio="Imperial Radch", name="Ann Leckie",
publisher=cls.publisher, mentor=cls.mentor, bio="Imperial Radch",
publisher=cls.publisher,
mentor=cls.mentor,
) )
cls.editor = Author.objects.create( cls.editor = Author.objects.create(
name="Devi Pillai", bio="Editor", publisher=cls.publisher 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.tag_space = Tag.objects.create(slug="space-opera", label="Space Opera")
cls.book = Book.objects.create( cls.book = Book.objects.create(
title="Ancillary Justice", isbn="9780316246620", title="Ancillary Justice",
page_count=386, is_published=True, isbn="9780316246620",
author=cls.author, editor=cls.editor, page_count=386,
is_published=True,
author=cls.author,
editor=cls.editor,
) )
cls.book.tags.add(cls.tag_sf, cls.tag_space) cls.book.tags.add(cls.tag_sf, cls.tag_space)
@@ -211,8 +223,12 @@ class TestShapeQuery(TestCase):
cls.ch2 = Chapter.objects.create( cls.ch2 = Chapter.objects.create(
book=cls.book, number=2, title="The Ship", word_count=4800 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(
Section.objects.create(chapter=cls.ch1, heading="Discovery", body="...", position=1) 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.root_cat = Category.objects.create(name="Fiction")
cls.child_cat = Category.objects.create(name="Sci-Fi", parent=cls.root_cat) 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): def test_nullable_fk_returns_none(self):
book_no_editor = Book.objects.create( book_no_editor = Book.objects.create(
title="Provenance", isbn="9780316246699", title="Provenance",
page_count=448, is_published=True, isbn="9780316246699",
author=self.author, editor=None, page_count=448,
is_published=True,
author=self.author,
editor=None,
) )
results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk)) results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk))
self.assertEqual(len(results), 1) self.assertEqual(len(results), 1)
@@ -330,7 +349,6 @@ class TestShapeQuery(TestCase):
class TestDiff(TestCase): class TestDiff(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.publisher = Publisher.objects.create(name="Tor", country="US") cls.publisher = Publisher.objects.create(name="Tor", country="US")
@@ -338,8 +356,11 @@ class TestDiff(TestCase):
name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher
) )
cls.book = Book.objects.create( cls.book = Book.objects.create(
title="Mistborn", isbn="9780765311788", title="Mistborn",
page_count=541, is_published=True, author=cls.author, isbn="9780765311788",
page_count=541,
is_published=True,
author=cls.author,
) )
cls.ch1 = Chapter.objects.create( cls.ch1 = Chapter.objects.create(
book=cls.book, number=1, title="Ash", word_count=6000 book=cls.book, number=1, title="Ash", word_count=6000
@@ -352,8 +373,11 @@ class TestDiff(TestCase):
def test_diff_no_changes(self): def test_diff_no_changes(self):
shape = BookCardShape( shape = BookCardShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
) )
d = shape.diff() d = shape.diff()
@@ -362,8 +386,11 @@ class TestDiff(TestCase):
def test_diff_detects_field_change(self): def test_diff_detects_field_change(self):
shape = BookCardShape( shape = BookCardShape(
id=self.book.pk, title="Mistborn: The Final Empire", id=self.book.pk,
isbn="9780765311788", page_count=541, is_published=True, title="Mistborn: The Final Empire",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
) )
d = shape.diff() d = shape.diff()
@@ -372,8 +399,11 @@ class TestDiff(TestCase):
def test_diff_multiple_field_changes(self): def test_diff_multiple_field_changes(self):
shape = BookCardShape( shape = BookCardShape(
id=self.book.pk, title="Mistborn: TFE", id=self.book.pk,
isbn="9780765311788", page_count=600, is_published=True, title="Mistborn: TFE",
isbn="9780765311788",
page_count=600,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
) )
d = shape.diff() d = shape.diff()
@@ -396,12 +426,23 @@ class TestDiff(TestCase):
def test_nested_diff_detects_updated_chapter(self): def test_nested_diff_detects_updated_chapter(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ chapters=[
ChapterShape(id=self.ch1.pk, number=1, title="Ash Falls", word_count=6000, sections=[]), ChapterShape(
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]), 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=[], tags=[],
) )
@@ -411,13 +452,22 @@ class TestDiff(TestCase):
def test_nested_diff_detects_created(self): def test_nested_diff_detects_created(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ chapters=[
ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]), ChapterShape(
ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]), id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]
ChapterShape(id=None, number=3, title="New Chapter", word_count=0, 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=[], tags=[],
) )
@@ -426,11 +476,16 @@ class TestDiff(TestCase):
def test_nested_diff_detects_deleted(self): def test_nested_diff_detects_deleted(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ 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=[], tags=[],
) )
@@ -439,12 +494,23 @@ class TestDiff(TestCase):
def test_nested_diff_combined_operations(self): def test_nested_diff_combined_operations(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[ chapters=[
ChapterShape(id=self.ch1.pk, number=1, title="Ash Rewritten", word_count=7000, sections=[]), ChapterShape(
ChapterShape(id=None, number=3, title="Epilogue", word_count=2000, sections=[]), 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=[], tags=[],
) )
@@ -469,10 +535,14 @@ class TestDiff(TestCase):
def test_diff_strict_shows_valid_names(self): def test_diff_strict_shows_valid_names(self):
shape = BookDetailShape( shape = BookDetailShape(
id=self.book.pk, title="Mistborn", isbn="9780765311788", id=self.book.pk,
page_count=541, is_published=True, title="Mistborn",
isbn="9780765311788",
page_count=541,
is_published=True,
author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"),
chapters=[], tags=[], chapters=[],
tags=[],
) )
d = shape.diff() d = shape.diff()
with self.assertRaises(AttributeError) as ctx: with self.assertRaises(AttributeError) as ctx:
@@ -506,8 +576,11 @@ class TestDiff(TestCase):
def test_diff_many_batched_query(self): def test_diff_many_batched_query(self):
book2 = Book.objects.create( book2 = Book.objects.create(
title="Warbreaker", isbn="9780765320308", title="Warbreaker",
page_count=592, is_published=True, author=self.author, isbn="9780765320308",
page_count=592,
is_published=True,
author=self.author,
) )
items = [ items = [
FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True), FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True),
@@ -526,7 +599,6 @@ class TestDiff(TestCase):
class TestEdgeCases(TestCase): class TestEdgeCases(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.publisher = Publisher.objects.create(name="Edge Cases Ltd", country="XX") 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): def test_boolean_false_is_not_missing(self):
book = Book.objects.create( book = Book.objects.create(
title="Unpublished", isbn="0000000000000", title="Unpublished",
page_count=0, is_published=False, author=self.author, isbn="0000000000000",
page_count=0,
is_published=False,
author=self.author,
) )
results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk)) results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk))
self.assertIs(results[0].is_published, False) self.assertIs(results[0].is_published, False)
def test_zero_integer_is_not_missing(self): def test_zero_integer_is_not_missing(self):
book = Book.objects.create( book = Book.objects.create(
title="Empty", isbn="0000000000001", title="Empty",
page_count=0, is_published=False, author=self.author, isbn="0000000000001",
page_count=0,
is_published=False,
author=self.author,
) )
results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk)) results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk))
self.assertEqual(results[0].page_count, 0) self.assertEqual(results[0].page_count, 0)
@@ -562,8 +640,10 @@ class TestEdgeCases(TestCase):
def test_large_queryset(self): def test_large_queryset(self):
books = [ books = [
Book( Book(
title=f"Book {i}", isbn=f"{i:013d}", title=f"Book {i}",
page_count=i * 10, is_published=i % 2 == 0, isbn=f"{i:013d}",
page_count=i * 10,
is_published=i % 2 == 0,
author=self.author, author=self.author,
) )
for i in range(100) for i in range(100)
@@ -574,8 +654,11 @@ class TestEdgeCases(TestCase):
def test_diff_on_boolean_change(self): def test_diff_on_boolean_change(self):
book = Book.objects.create( book = Book.objects.create(
title="Toggle", isbn="1111111111111", title="Toggle",
page_count=100, is_published=False, author=self.author, isbn="1111111111111",
page_count=100,
is_published=False,
author=self.author,
) )
shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True) shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True)
d = shape.diff() d = shape.diff()
@@ -584,8 +667,11 @@ class TestEdgeCases(TestCase):
def test_diff_unchanged_returns_empty(self): def test_diff_unchanged_returns_empty(self):
book = Book.objects.create( book = Book.objects.create(
title="Same", isbn="2222222222222", title="Same",
page_count=200, is_published=True, author=self.author, isbn="2222222222222",
page_count=200,
is_published=True,
author=self.author,
) )
shape = FlatBookShape(id=book.pk, title="Same", is_published=True) shape = FlatBookShape(id=book.pk, title="Same", is_published=True)
d = shape.diff() d = shape.diff()

View File

@@ -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) - GET /session/ - Initialize session and get CSRF token (for SSR)
- POST /call/ - Server function calls (HTTP transport) - POST /call/ - Server function calls (HTTP transport)
Security: Security:
- Schema export is NOT exposed over HTTP to prevent API enumeration - 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 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 from .client.executor import function_call_view
app_name = "djarea" app_name = "mizan"
@ensure_csrf_cookie @ensure_csrf_cookie

View File

@@ -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 from django.db import models
@@ -23,7 +27,7 @@ class EmailUserManager(BaseUserManager):
class EmailUser(AbstractBaseUser, PermissionsMixin): class EmailUser(AbstractBaseUser, PermissionsMixin):
"""Minimal user model with email as USERNAME_FIELD. """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) User.objects.create_user(email="...", password="...", is_staff=True)
""" """
@@ -90,7 +94,11 @@ class Book(TimestampMixin):
is_published = models.BooleanField(default=False) is_published = models.BooleanField(default=False)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books") author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
editor = models.ForeignKey( 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") tags = models.ManyToManyField(Tag, blank=True, related_name="books")
@@ -112,7 +120,9 @@ class Chapter(TimestampMixin):
class Section(models.Model): class Section(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 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) heading = models.CharField(max_length=300)
body = models.TextField(default="") body = models.TextField(default="")
position = models.IntegerField(default=0) position = models.IntegerField(default=0)

View File

@@ -1,5 +1,5 @@
""" """
Django settings for running djarea's test suite standalone. Django settings for running mizan's test suite standalone.
Usage: Usage:
cd django/ cd django/
@@ -22,7 +22,7 @@ INSTALLED_APPS = [
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"djarea", "mizan",
"tests", "tests",
] ]

View File

@@ -1,5 +1,5 @@
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("api/djarea/", include("djarea.urls")), path("api/mizan/", include("mizan.urls")),
] ]

View File

@@ -1,9 +1,9 @@
/** /**
* Djarea E2E Integration Tests * mizan E2E Integration Tests
* *
* Real Chromium → Real React app (generated hooks) → Real Django backend * 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' import { test, expect } from '@playwright/test'
@@ -150,7 +150,7 @@ test.describe('generated form hooks', () => {
expect(result.fields.password).toBeDefined() 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') await fixture(page, 'form-contact-schema')
const result = await getResult(page) const result = await getResult(page)
expect(result.title).toBe('Contact Us') expect(result.title).toBe('Contact Us')

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<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> <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html> </html>

View File

@@ -1,5 +1,5 @@
{ {
"name": "djarea-e2e-harness", "name": "mizan-e2e-harness",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -7,7 +7,7 @@
"dev": "vite --port 5174" "dev": "vite --port 5174"
}, },
"dependencies": { "dependencies": {
"@rythazhur/djarea": "file:../../react", "@rythazhur/mizan": "file:../../react",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@@ -1,5 +1,5 @@
/** /**
* Djarea API - Consolidated Exports * mizan API - Consolidated Exports
* *
* Import everything from here: * 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 // Regenerate with: npm run schemas
// ============================================================================= // =============================================================================
// Djarea Provider & Hooks // mizan Provider & Hooks
// ============================================================================= // =============================================================================
export { export {
@@ -55,9 +55,9 @@ export {
useJwtObtain, useJwtObtain,
useJwtRefresh, useJwtRefresh,
// Re-exports from djarea library // Re-exports from mizan library
useDjarea, usemizan,
useDjareaStatus, usemizanStatus,
usePush, usePush,
DjangoError, DjangoError,
type ConnectionStatus, type ConnectionStatus,

View File

@@ -1,7 +1,7 @@
/** /**
* E2E Test Fixtures * 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. * Playwright reads the DOM to verify behavior.
* *
* URL hash selects the fixture: #echo, #add, #multiply, etc. * URL hash selects the fixture: #echo, #add, #multiply, etc.
@@ -9,7 +9,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
// Generated typed hooks — the actual Djarea API // Generated typed hooks — the actual mizan API
import { import {
DjangoContext, DjangoContext,
useEcho, useEcho,
@@ -24,7 +24,7 @@ import {
usePermissionCheckFn, usePermissionCheckFn,
useCurrentUser, useCurrentUser,
DjangoError, DjangoError,
useDjarea, useMizan,
} from './api/generated.django' } from './api/generated.django'
import { useContactForm, useLoginForm } from './api/generated.forms' import { useContactForm, useLoginForm } from './api/generated.forms'
import { useChatChannel } from './api/generated.channels.hooks' import { useChatChannel } from './api/generated.channels.hooks'
@@ -121,7 +121,7 @@ function Multiply() {
function NotFound() { function NotFound() {
// Deliberately call a non-existent function via the raw primitive // Deliberately call a non-existent function via the raw primitive
const { call } = useDjarea() const { call } = useMizan()
const [error, setError] = useState<unknown>() const [error, setError] = useState<unknown>()
useEffect(() => { call('does_not_exist').catch(setError) }, [call]) useEffect(() => { call('does_not_exist').catch(setError) }, [call])
return <Result error={error} /> return <Result error={error} />

View File

@@ -4,7 +4,7 @@ import { Fixtures } from './fixtures'
function App() { function App() {
return ( return (
<DjangoContext baseUrl="/api/djarea"> <DjangoContext baseUrl="/api/mizan">
<Fixtures /> <Fixtures />
</DjangoContext> </DjangoContext>
) )

View File

@@ -8,17 +8,17 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'djarea/channels': path.join(reactPkg, 'channels/index.ts'), 'mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'djarea/client/react': path.join(reactPkg, 'client/react.ts'), 'mizan/client/react': path.join(reactPkg, 'client/react.ts'),
'djarea/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'), 'mizan/client/nextjs': path.join(reactPkg, 'client/nextjs.tsx'),
'djarea/client': path.join(reactPkg, 'client/index.ts'), 'mizan/client': path.join(reactPkg, 'client/index.ts'),
'djarea/jwt': path.join(reactPkg, 'jwt/index.ts'), 'mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
'djarea/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'), 'mizan/allauth/nextjs': path.join(reactPkg, 'allauth/nextjs.tsx'),
'djarea/allauth': path.join(reactPkg, 'allauth/index.ts'), 'mizan/allauth': path.join(reactPkg, 'allauth/index.ts'),
'djarea': path.join(reactPkg, 'index.ts'), 'mizan': path.join(reactPkg, 'index.ts'),
'@rythazhur/djarea/channels': path.join(reactPkg, 'channels/index.ts'), '@rythazhur/mizan/channels': path.join(reactPkg, 'channels/index.ts'),
'@rythazhur/djarea/jwt': path.join(reactPkg, 'jwt/index.ts'), '@rythazhur/mizan/jwt': path.join(reactPkg, 'jwt/index.ts'),
'@rythazhur/djarea': path.join(reactPkg, 'index.ts'), '@rythazhur/mizan': path.join(reactPkg, 'index.ts'),
}, },
}, },
server: { server: {

View File

@@ -6,4 +6,4 @@ class TestAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
def ready(self): def ready(self):
import testapp.djarea_clients # noqa: F401 import testapp.mizan_clients # noqa: F401

View File

@@ -6,9 +6,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
django.setup() django.setup()
from django.core.asgi import get_asgi_application 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 # 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()) application = wrap_asgi(get_asgi_application())

View File

@@ -11,12 +11,12 @@ from django import forms
from django.http import HttpRequest from django.http import HttpRequest
from pydantic import BaseModel from pydantic import BaseModel
from djarea.client import ServerFunction, client from mizan.client import ServerFunction, client
from djarea.channels import ReactChannel from mizan.channels import ReactChannel
from djarea.setup.registry import register, register_form, register_as from mizan.setup.registry import register, register_form, register_as
from djarea.channels import register as register_channel from mizan.channels import register as register_channel
from djarea.forms import DjareaFormMixin, DjareaFormMeta from mizan.forms import mizanFormMixin, mizanFormMeta
from djarea.jwt import jwt_obtain, jwt_refresh from mizan.jwt import jwt_obtain, jwt_refresh
# ============================================================================= # =============================================================================
@@ -57,9 +57,9 @@ class WhoamiOutput(BaseModel):
@client(auth=True) @client(auth=True)
def whoami(request: HttpRequest) -> WhoamiOutput: def whoami(request: HttpRequest) -> WhoamiOutput:
return WhoamiOutput( return WhoamiOutput(
user_id=getattr(request.user, 'id', None), user_id=getattr(request.user, "id", None),
email=getattr(request.user, 'email', ''), email=getattr(request.user, "email", ""),
is_staff=getattr(request.user, 'is_staff', False), is_staff=getattr(request.user, "is_staff", False),
) )
@@ -197,18 +197,20 @@ register_channel(PresenceChannel, "presence")
# --- Staff-only --- # --- Staff-only ---
@client(auth='staff') @client(auth="staff")
def staff_only(request: HttpRequest) -> EchoOutput: def staff_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message=f"staff:{request.user.email}") return EchoOutput(message=f"staff:{request.user.email}")
register(staff_only, "staff_only") register(staff_only, "staff_only")
# --- Superuser-only --- # --- Superuser-only ---
@client(auth='superuser') @client(auth="superuser")
def superuser_only(request: HttpRequest) -> EchoOutput: def superuser_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message=f"superuser:{request.user.email}") return EchoOutput(message=f"superuser:{request.user.email}")
register(superuser_only, "superuser_only") register(superuser_only, "superuser_only")
@@ -216,12 +218,14 @@ register(superuser_only, "superuser_only")
def check_verified_email(request): def check_verified_email(request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return False return False
return getattr(request.user, 'email', '').endswith('@verified.com') return getattr(request.user, "email", "").endswith("@verified.com")
@client(auth=check_verified_email) @client(auth=check_verified_email)
def verified_only(request: HttpRequest) -> EchoOutput: def verified_only(request: HttpRequest) -> EchoOutput:
return EchoOutput(message="verified") return EchoOutput(message="verified")
register(verified_only, "verified_only") register(verified_only, "verified_only")
@@ -235,7 +239,8 @@ class CurrentUserOutput(BaseModel):
email: str email: str
is_staff: bool is_staff: bool
@client(context='global')
@client(context="global")
def current_user(request: HttpRequest) -> CurrentUserOutput: def current_user(request: HttpRequest) -> CurrentUserOutput:
if request.user.is_authenticated: if request.user.is_authenticated:
return CurrentUserOutput( return CurrentUserOutput(
@@ -245,16 +250,19 @@ def current_user(request: HttpRequest) -> CurrentUserOutput:
) )
return CurrentUserOutput(authenticated=False, email="", is_staff=False) return CurrentUserOutput(authenticated=False, email="", is_staff=False)
register(current_user, "current_user") register(current_user, "current_user")
class GreetOutput(BaseModel): class GreetOutput(BaseModel):
greeting: str greeting: str
@client(context='local')
@client(context="local")
def greet(request: HttpRequest, name: str) -> GreetOutput: def greet(request: HttpRequest, name: str) -> GreetOutput:
return GreetOutput(greeting=f"Hello, {name}!") return GreetOutput(greeting=f"Hello, {name}!")
register(greet, "greet") register(greet, "greet")
@@ -267,9 +275,11 @@ class MultiplyInput(BaseModel):
x: int x: int
y: int y: int
class MultiplyOutput(BaseModel): class MultiplyOutput(BaseModel):
product: int product: int
@register_as("multiply") @register_as("multiply")
class Multiply(ServerFunction): class Multiply(ServerFunction):
Input = MultiplyInput Input = MultiplyInput
@@ -288,6 +298,7 @@ class Multiply(ServerFunction):
def not_implemented_fn(request: HttpRequest) -> EchoOutput: def not_implemented_fn(request: HttpRequest) -> EchoOutput:
raise NotImplementedError("This feature is not yet implemented") raise NotImplementedError("This feature is not yet implemented")
register(not_implemented_fn, "not_implemented_fn") register(not_implemented_fn, "not_implemented_fn")
@@ -295,6 +306,7 @@ register(not_implemented_fn, "not_implemented_fn")
def buggy_fn(request: HttpRequest) -> EchoOutput: def buggy_fn(request: HttpRequest) -> EchoOutput:
raise RuntimeError("Unexpected internal failure") raise RuntimeError("Unexpected internal failure")
register(buggy_fn, "buggy_fn") register(buggy_fn, "buggy_fn")
@@ -304,6 +316,7 @@ def permission_check_fn(request: HttpRequest, secret: str) -> EchoOutput:
raise PermissionError("Wrong secret") raise PermissionError("Wrong secret")
return EchoOutput(message="access granted") return EchoOutput(message="access granted")
register(permission_check_fn, "permission_check_fn") register(permission_check_fn, "permission_check_fn")
@@ -315,21 +328,22 @@ register(permission_check_fn, "permission_check_fn")
@client(websocket=True, auth=True) @client(websocket=True, auth=True)
def ws_whoami(request: HttpRequest) -> WhoamiOutput: def ws_whoami(request: HttpRequest) -> WhoamiOutput:
return WhoamiOutput( return WhoamiOutput(
user_id=getattr(request.user, 'id', None), user_id=getattr(request.user, "id", None),
email=getattr(request.user, 'email', ''), email=getattr(request.user, "email", ""),
is_staff=getattr(request.user, 'is_staff', False), is_staff=getattr(request.user, "is_staff", False),
) )
register(ws_whoami, "ws_whoami") register(ws_whoami, "ws_whoami")
# ============================================================================= # =============================================================================
# DjareaFormMixin Forms # mizanFormMixin Forms
# ============================================================================= # =============================================================================
class ContactForm(DjareaFormMixin, forms.Form): class ContactForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="contact", name="contact",
title="Contact Us", title="Contact Us",
subtitle="We'd love to hear from you", subtitle="We'd love to hear from you",
@@ -351,8 +365,8 @@ class ContactForm(DjareaFormMixin, forms.Form):
# ============================================================================= # =============================================================================
class ItemForm(DjareaFormMixin, forms.Form): class ItemForm(mizanFormMixin, forms.Form):
djarea = DjareaFormMeta( mizan = mizanFormMeta(
name="item", name="item",
title="Items", title="Items",
submit_label="Save Items", submit_label="Save Items",
@@ -363,7 +377,10 @@ class ItemForm(DjareaFormMixin, forms.Form):
quantity = forms.IntegerField(min_value=1, label="Quantity") quantity = forms.IntegerField(min_value=1, label="Quantity")
def on_submit_success(self, request): 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 text: str
def authorize(self, params=None): def authorize(self, params=None):
return getattr(self.user, 'is_authenticated', False) return getattr(self.user, "is_authenticated", False)
def group(self, params=None): def group(self, params=None):
return "private_global" return "private_global"
register_channel(PrivateChannel, "private") register_channel(PrivateChannel, "private")

View File

@@ -20,7 +20,7 @@ INSTALLED_APPS = [
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"djarea", "mizan",
"testapp", "testapp",
] ]

View File

@@ -1,5 +1,5 @@
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("api/djarea/", include("djarea.urls")), path("api/mizan/", include("mizan.urls")),
] ]

View File

@@ -1,5 +1,5 @@
{ {
"name": "djarea", "name": "mizan",
"version": "1.0.0", "version": "1.0.0",
"description": "Django + React server functions framework.", "description": "Django + React server functions framework.",
"main": "index.js", "main": "index.js",

View File

@@ -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 ## Install
```bash ```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 ## Usage
@@ -30,8 +30,8 @@ export default {
### 2. Generate ### 2. Generate
```bash ```bash
npx djarea-generate # once npx mizan-generate # once
npx djarea-generate --watch # dev mode npx mizan-generate --watch # dev mode
``` ```
### 3. Wrap your app ### 3. Wrap your app
@@ -74,7 +74,7 @@ chat.messages // typed, reactive
| File | Contents | | File | Contents |
|------|----------| |------|----------|
| `generated.django.tsx` | `DjangoContext` + typed hooks | | `generated.django.tsx` | `DjangoContext` + typed hooks |
| `generated.djarea.ts` | Pydantic types | | `generated.mizan.ts` | Pydantic types |
| `generated.forms.ts` | Form hooks with Zod | | `generated.forms.ts` | Form hooks with Zod |
| `generated.channels.hooks.tsx` | Channel hooks | | `generated.channels.hooks.tsx` | Channel hooks |
| `index.ts` | Re-exports everything | | `index.ts` | Re-exports everything |
@@ -83,11 +83,11 @@ chat.messages // typed, reactive
| Import | When to use | | Import | When to use |
|--------|------------| |--------|------------|
| `@rythazhur/djarea` | Core: DjareaProvider, hooks, forms, errors | | `@rythazhur/mizan` | Core: mizanProvider, hooks, forms, errors |
| `@rythazhur/djarea/channels` | WebSocket channels | | `@rythazhur/mizan/channels` | WebSocket channels |
| `@rythazhur/djarea/jwt` | JWT token management | | `@rythazhur/mizan/jwt` | JWT token management |
| `@rythazhur/djarea/client` | HTTP clients (CSR/SSR) | | `@rythazhur/mizan/client` | HTTP clients (CSR/SSR) |
| `@rythazhur/djarea/allauth` | Allauth UI components | | `@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. These are **library internals** used by the generated code. You should import from `@/api` (your generated index), not from the library directly.

View File

@@ -1,5 +1,5 @@
{ {
"name": "@rythazhur/djarea", "name": "@rythazhur/mizan",
"version": "0.1.1", "version": "0.1.1",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -39,7 +39,7 @@
} }
}, },
"bin": { "bin": {
"djarea-generate": "./dist/generator/cli.mjs" "mizan-generate": "./dist/generator/cli.mjs"
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('src/generator','dist/generator',{recursive:true})\"", "build": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('src/generator','dist/generator',{recursive:true})\"",

View File

@@ -10,10 +10,10 @@
import React from 'react' import React from 'react'
import { render, screen, waitFor, act } from '@testing-library/react' import { render, screen, waitFor, act } from '@testing-library/react'
import { import {
DjareaProvider, MizanProvider,
useDjarea, useMizan,
useDjareaStatus, useMizanStatus,
useDjareaCall, useMizanCall,
// Legacy aliases for backwards compatibility tests // Legacy aliases for backwards compatibility tests
DjangoContext, DjangoContext,
useDjango, useDjango,
@@ -27,18 +27,18 @@ import { describeIntegration, BACKEND_URL } from '../testing'
// Unit Tests (no backend required) // Unit Tests (no backend required)
// ============================================================================ // ============================================================================
describe('Djarea Context (unit)', () => { describe('mizan Context (unit)', () => {
describe('useDjarea hook', () => { describe('useMizan hook', () => {
it('should throw when used outside provider', () => { it('should throw when used outside provider', () => {
function TestComponent() { function TestComponent() {
useDjarea() useMizan()
return <div>Test</div> return <div>Test</div>
} }
const consoleSpy = jest.spyOn(console, 'error').mockImplementation() const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
expect(() => render(<TestComponent />)).toThrow( expect(() => render(<TestComponent />)).toThrow(
'useDjarea must be used within a DjareaProvider' 'useMizan must be used within a MizanProvider'
) )
consoleSpy.mockRestore() consoleSpy.mockRestore()
@@ -48,14 +48,14 @@ describe('Djarea Context (unit)', () => {
let contextValue: any = null let contextValue: any = null
function TestComponent() { function TestComponent() {
contextValue = useDjarea() contextValue = useMizan()
return <div>Test</div> return <div>Test</div>
} }
render( render(
<DjareaProvider autoConnect={false}> <MizanProvider autoConnect={false}>
<TestComponent /> <TestComponent />
</DjareaProvider> </MizanProvider>
) )
expect(contextValue).not.toBeNull() 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', () => { it('should return disconnected when autoConnect is false', () => {
function TestComponent() { function TestComponent() {
const status = useDjareaStatus() const status = useMizanStatus()
return <div data-testid="status">{status}</div> return <div data-testid="status">{status}</div>
} }
render( render(
<DjareaProvider autoConnect={false}> <MizanProvider autoConnect={false}>
<TestComponent /> <TestComponent />
</DjareaProvider> </MizanProvider>
) )
expect(screen.getByTestId('status')).toHaveTextContent('disconnected') expect(screen.getByTestId('status')).toHaveTextContent('disconnected')
@@ -85,7 +85,7 @@ describe('Djarea Context (unit)', () => {
let contextValue: any = null let contextValue: any = null
function TestComponent() { function TestComponent() {
contextValue = useDjarea() contextValue = useMizan()
return <div>Test</div> return <div>Test</div>
} }
@@ -95,9 +95,9 @@ describe('Djarea Context (unit)', () => {
} }
render( render(
<DjareaProvider hydration={hydration} autoConnect={false}> <MizanProvider hydration={hydration} autoConnect={false}>
<TestComponent /> <TestComponent />
</DjareaProvider> </MizanProvider>
) )
expect(contextValue.getContext('auth_status')).toEqual({ is_authenticated: false }) expect(contextValue.getContext('auth_status')).toEqual({ is_authenticated: false })
@@ -110,7 +110,7 @@ describe('Djarea Context (unit)', () => {
// Integration Tests (require running backend) // Integration Tests (require running backend)
// ============================================================================ // ============================================================================
describeIntegration('Djarea Context (integration)', () => { describeIntegration('mizan Context (integration)', () => {
describe('server function calls via HTTP', () => { describe('server function calls via HTTP', () => {
it('should call echo function and get response', async () => { it('should call echo function and get response', async () => {
let result: any = null let result: any = null
@@ -130,7 +130,7 @@ describeIntegration('Djarea Context (integration)', () => {
} }
render( render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent /> <TestComponent />
</DjangoContext> </DjangoContext>
) )
@@ -161,7 +161,7 @@ describeIntegration('Djarea Context (integration)', () => {
} }
render( render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent /> <TestComponent />
</DjangoContext> </DjangoContext>
) )
@@ -192,7 +192,7 @@ describeIntegration('Djarea Context (integration)', () => {
} }
render( render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent /> <TestComponent />
</DjangoContext> </DjangoContext>
) )
@@ -227,7 +227,7 @@ describeIntegration('Djarea Context (integration)', () => {
} }
render( render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent /> <TestComponent />
</DjangoContext> </DjangoContext>
) )
@@ -260,7 +260,7 @@ describeIntegration('Djarea Context (integration)', () => {
} }
render( render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent /> <TestComponent />
</DjangoContext> </DjangoContext>
) )
@@ -296,7 +296,7 @@ describeIntegration('Djarea Context (integration)', () => {
} }
render( render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
<TestComponent /> <TestComponent />
</DjangoContext> </DjangoContext>
) )

View File

@@ -28,7 +28,7 @@ function renderFormHook<TData extends Record<string, unknown>>(
) { ) {
return renderHook(() => useDjangoFormCore<TData>(config), { return renderHook(() => useDjangoFormCore<TData>(config), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <DjangoContext baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
{children} {children}
</DjangoContext> </DjangoContext>
), ),

View File

@@ -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. * Tests error paths and protocol correctness across HTTP, Forms, and WebSocket.
* Requires a running backend: docker-compose up * Requires a running backend: docker-compose up
@@ -10,22 +10,22 @@
import { renderHook, act } from '@testing-library/react' import { renderHook, act } from '@testing-library/react'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { describeIntegration, BACKEND_URL, WS_URL } from '../testing' import { describeIntegration, BACKEND_URL, WS_URL } from '../testing'
import { DjareaProvider, useDjarea } from '../context' import { MizanProvider, useMizan } from '../context'
import { DjangoError } from '../errors' import { DjangoError } from '../errors'
import { ChannelConnection } from '../channels/connection' import { ChannelConnection } from '../channels/connection'
import { RPCError } from '../channels/connection' import { RPCError } from '../channels/connection'
function Wrapper({ children }: { children: ReactNode }) { function Wrapper({ children }: { children: ReactNode }) {
return ( return (
<DjareaProvider baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}> <MizanProvider baseUrl={`${BACKEND_URL}/api/mizan`} autoConnect={false}>
{children} {children}
</DjareaProvider> </MizanProvider>
) )
} }
// Helper to get call function // Helper to get call function
function useCall() { function useCall() {
const { call } = useDjarea() const { call } = useMizan()
return call return call
} }
@@ -503,7 +503,7 @@ describeIntegration('Error code coverage', () => {
}) })
it('should return BAD_REQUEST for invalid JSON body', async () => { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
@@ -515,7 +515,7 @@ describeIntegration('Error code coverage', () => {
}) })
it('should return BAD_REQUEST for missing fn field', async () => { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
@@ -528,11 +528,11 @@ describeIntegration('Error code coverage', () => {
}) })
// ============================================================================ // ============================================================================
// Group 8: DjareaFormMixin integration // Group 8: mizanFormMixin integration
// ============================================================================ // ============================================================================
describeIntegration('DjareaFormMixin integration', () => { describeIntegration('mizanFormMixin integration', () => {
it('should return schema with title, subtitle, and submit_label from DjareaFormMeta', async () => { it('should return schema with title, subtitle, and submit_label from mizanFormMeta', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper }) const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null let response: any = null

View File

@@ -1,9 +1,9 @@
/** /**
* Re-export RouterAdapter from djarea/client. * Re-export RouterAdapter from mizan/client.
* *
* Allauth extends this with a required getParam method. * 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 { export interface RouterAdapter extends BaseRouterAdapter {
/** Get a specific route param (e.g., from /auth/[...path]) - required for allauth */ /** Get a specific route param (e.g., from /auth/[...path]) - required for allauth */

View File

@@ -6,7 +6,7 @@ import {
type DjangoFormState, type DjangoFormState,
type FormOptions, type FormOptions,
type FormErrors, type FormErrors,
} from 'djarea' } from 'mizan'
import { useAuthContext } from '../contexts/AuthContext' import { useAuthContext } from '../contexts/AuthContext'
import { useStyles } from '../contexts/StylesContext' import { useStyles } from '../contexts/StylesContext'
import { getAuthDetails, AuthDetails } from '../api' 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. * with styling consistent with the auth UI.
* *
* It fetches the form schema (including title, subtitle, fields, submit label) * It fetches the form schema (including title, subtitle, fields, submit label)

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAllauthAPI } from '../../contexts/APIContext' import { useAllauthAPI } from '../../contexts/APIContext'
import { useStyles } from '../../contexts/StylesContext' import { useStyles } from '../../contexts/StylesContext'
import { useDjangoFormCore } from 'djarea' import { useDjangoFormCore } from 'mizan'
import { SettingsSection, SettingsItem, SettingsList, Badge, Button } from './SettingsComponents' import { SettingsSection, SettingsItem, SettingsList, Badge, Button } from './SettingsComponents'
interface Email { interface Email {

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useDjangoFormCore } from 'djarea' import { useDjangoFormCore } from 'mizan'
import { useStyles } from '../../contexts/StylesContext' import { useStyles } from '../../contexts/StylesContext'
import { SettingsSection, Button } from './SettingsComponents' import { SettingsSection, Button } from './SettingsComponents'

View File

@@ -5,7 +5,7 @@
* 1. Define the base path for Django-initiated routes (must match HEADLESS_FRONTEND_URLS) * 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) * 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 { export interface AllauthConfig {

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useDjangoCSRClient, Auth } from 'djarea/client/react' import { useDjangoCSRClient, Auth } from 'mizan/client/react'
import { useAuthContext } from './AuthContext' import { useAuthContext } from './AuthContext'
import { createAPI, AllauthAPI, BrowserFormAction } from '../api' import { createAPI, AllauthAPI, BrowserFormAction } from '../api'

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { ReactNode, useEffect, useState } from 'react' 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 { RouterAdapter } from '../adapters/router'
import type { InitialAuth } from '../hydration' import type { InitialAuth } from '../hydration'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useDjangoCSRClient, Auth } from 'djarea/client/react' import { useDjangoCSRClient, Auth } from 'mizan/client/react'
import { useDjarea, useDjareaContext } from 'djarea' import { useMizan, useMizanContext } from 'mizan'
import { getAuthDetails, createAPI } from '../api' import { getAuthDetails, createAPI } from '../api'
import type { AllauthResponse } from '../types' import type { AllauthResponse } from '../types'
import getAuthChangeEvent from '../events' import getAuthChangeEvent from '../events'
@@ -30,7 +30,7 @@ export function AuthContext({
auth: initialAuth, auth: initialAuth,
}: AuthContextProps) { }: AuthContextProps) {
const client = useDjangoCSRClient(Auth.SESSION) const client = useDjangoCSRClient(Auth.SESSION)
const { refreshAllContexts } = useDjarea() const { refreshAllContexts } = useMizan()
const [auth, setAuth] = useState(initialAuth) const [auth, setAuth] = useState(initialAuth)
const [event, setEvent] = useState('') const [event, setEvent] = useState('')
const prevAuth = useRef(initialAuth) 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. * This uses the generic mizan hook to access the 'user' context.
* The backend defines this context in lib/djarea/allauth/contexts.py: * The backend defines this context in lib/mizan/allauth/contexts.py:
* *
* @client(context='global') * @client(context='global')
* def user(request) -> UserOutput | None: * 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) * @typeParam T - User type (defaults to AllauthUser, products can use more specific types)
*/ */
export function useUser<T extends AllauthUser = AllauthUser>(): T { 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) // Return empty object cast to T if user is undefined (not loaded)
// This matches the previous behavior and allows optional chaining // This matches the previous behavior and allows optional chaining
return (user ?? {}) as T return (user ?? {}) as T

View File

@@ -1,4 +1,4 @@
import type { DjangoHTTPClient } from 'djarea/client' import type { DjangoHTTPClient } from 'mizan/client'
import { createAPI } from './api' import { createAPI } from './api'
import type { AllauthResponse } from './types' import type { AllauthResponse } from './types'

View File

@@ -1,5 +1,5 @@
/** /**
* djarea/allauth * mizan/allauth
* *
* React integration for django-allauth headless API. * React integration for django-allauth headless API.
* Framework-agnostic - works with Next.js, Remix, React Router, etc. * Framework-agnostic - works with Next.js, Remix, React Router, etc.
@@ -9,9 +9,9 @@
* ```tsx * ```tsx
* // layout.tsx * // layout.tsx
* import { cookies } from 'next/headers' * import { cookies } from 'next/headers'
* import { createDjangoSSRClient } from 'djarea/client' * import { createDjangoSSRClient } from 'mizan/client'
* import { getInitialAuth } from 'djarea/allauth' * import { getInitialAuth } from 'mizan/allauth'
* import { NextAllauthContext } from 'djarea/allauth/nextjs' * import { NextAllauthContext } from 'mizan/allauth/nextjs'
* *
* export default async function RootLayout({ children }) { * export default async function RootLayout({ children }) {
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() }) * const ssrClient = createDjangoSSRClient({ cookies: await cookies() })

View File

@@ -1,14 +1,14 @@
'use client' 'use client'
/** /**
* Next.js adapter for djarea/allauth. * Next.js adapter for mizan/allauth.
* *
* Usage: * Usage:
* ```tsx * ```tsx
* // In layout.tsx (server component) * // In layout.tsx (server component)
* import { createDjangoSSRClient } from 'djarea/client' * import { createDjangoSSRClient } from 'mizan/client'
* import { getInitialAuth } from 'djarea/allauth' * import { getInitialAuth } from 'mizan/allauth'
* import { NextAllauthContext } from 'djarea/allauth/nextjs' * import { NextAllauthContext } from 'mizan/allauth/nextjs'
* *
* export default async function RootLayout({ children }) { * export default async function RootLayout({ children }) {
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() }) * const ssrClient = createDjangoSSRClient({ cookies: await cookies() })

View File

@@ -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. * Supports both pub/sub channels AND RPC calls over the same connection.
*/ */

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
/** /**
* React context for djarea/channels * React context for mizan/channels
*/ */
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
/** /**
* React hooks for djarea/channels * React hooks for mizan/channels
* *
* Includes pub/sub channel hooks AND RPC hooks. * Includes pub/sub channel hooks AND RPC hooks.
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* djarea/channels * mizan/channels
* *
* Real-time WebSocket communication with Django Channels. * Real-time WebSocket communication with Django Channels.
* Type-safe bidirectional messaging. * Type-safe bidirectional messaging.
@@ -8,7 +8,7 @@
* *
* ```tsx * ```tsx
* // layout.tsx * // layout.tsx
* import { ChannelProvider } from 'djarea/channels' * import { ChannelProvider } from 'mizan/channels'
* *
* export default function Layout({ children }) { * export default function Layout({ children }) {
* return ( * return (
@@ -36,7 +36,7 @@
* *
* ```tsx * ```tsx
* // Using raw hook (for custom channels) * // Using raw hook (for custom channels)
* import { useChannel } from 'djarea/channels' * import { useChannel } from 'mizan/channels'
* *
* function CustomChannel() { * function CustomChannel() {
* const channel = useChannel< * const channel = useChannel<

View File

@@ -1,5 +1,5 @@
/** /**
* Types for djarea/channels * Types for mizan/channels
*/ */
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'

Some files were not shown because too many files have changed in this diff Show More