Rename djarea to mizan and fix React casing conventions
Rename the package from djarea to mizan across the entire codebase — Python package, React library, generators, tests, and examples. Fix JSX/hook casing (MizanProvider, useMizan, etc.) that broke when the original PascalCase names were lowercased during the rename. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
gcc \
|
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
|
||||||
|
|
||||||
|
|||||||
2
MIZAN.md
2
MIZAN.md
@@ -6,7 +6,7 @@ This plan was written by Ryth's Claude.ai session after an extended design conve
|
|||||||
reviewing the full codebase, the original @compose discussion from January 2025, and
|
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.
|
||||||
|
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -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
|
||||||
|
|||||||
333
README.md
333
README.md
@@ -1,94 +1,84 @@
|
|||||||
# DJAREA
|
# mizan
|
||||||
|
|
||||||
A modern Django + React Framework for perfectionists with deadlines.
|
Django + React server functions framework. RPC, not REST.
|
||||||
|
|
||||||
Write a Pydantic function, add the @client decorator, use configurable **Shape** types for your models.
|
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()
|
||||||
@@ -128,79 +122,90 @@ function MyComponent() {
|
|||||||
} 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
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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(
|
||||||
|
FileEntry(
|
||||||
name=entry.name,
|
name=entry.name,
|
||||||
path=str(entry),
|
path=str(entry),
|
||||||
is_dir=entry.is_dir(),
|
is_dir=entry.is_dir(),
|
||||||
size=stat.st_size if not entry.is_dir() else 0,
|
size=stat.st_size if not entry.is_dir() else 0,
|
||||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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_*"]
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from djarea.shapes.core import Diff, NestedDiff, Shape
|
|
||||||
|
|
||||||
__all__ = ["Diff", "NestedDiff", "Shape"]
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Djarea - Django + React unified framework
|
mizan - Django + React unified framework
|
||||||
|
|
||||||
Server functions are the core primitive. Everything else builds on them.
|
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",
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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}",
|
"error": f"Already subscribed to {channel_name}",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"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}",
|
"error": f"Invalid params: {e}",
|
||||||
"channel": channel_name,
|
"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",
|
"error": "Authorization failed",
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not authorized:
|
if not authorized:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"error": "Not authorized",
|
"error": "Not authorized",
|
||||||
"channel": channel_name,
|
"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}",
|
"error": f"Failed to subscribe: {e}",
|
||||||
"channel": channel_name,
|
"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,
|
"subscribed": True,
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"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,
|
"unsubscribed": True,
|
||||||
"channel": channel_name,
|
"channel": channel_name,
|
||||||
"params": params_dict,
|
"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}",
|
"error": f"Not subscribed to {channel_name}",
|
||||||
"channel": 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",
|
"error": f"Channel {channel_name} does not accept messages",
|
||||||
"channel": channel_name,
|
"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}",
|
"error": f"Invalid message: {e}",
|
||||||
"channel": channel_name,
|
"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}",
|
"error": f"Message handling failed: {e}",
|
||||||
"channel": channel_name,
|
"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,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "BAD_REQUEST",
|
"code": "BAD_REQUEST",
|
||||||
"message": "Missing 'fn' field",
|
"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,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "NOT_FOUND",
|
"code": "NOT_FOUND",
|
||||||
"message": f"Function '{fn_name}' 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,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
"code": "FORBIDDEN",
|
"code": "FORBIDDEN",
|
||||||
"message": "This function is HTTP-only. Use POST /api/djarea/call/ instead.",
|
"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,7 +473,8 @@ 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,
|
"id": request_id,
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": {
|
"error": {
|
||||||
@@ -443,13 +482,16 @@ class DjangoReactConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
"message": result.message,
|
"message": result.message,
|
||||||
**({"details": result.details} if result.details else {}),
|
**({"details": result.details} if result.details else {}),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"data": result.data,
|
"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"),
|
"channel": event.get("channel"),
|
||||||
"params": event.get("params", {}),
|
"params": event.get("params", {}),
|
||||||
"type": event.get("message_type", "message"),
|
"type": event.get("message_type", "message"),
|
||||||
"data": event.get("data", {}),
|
"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",
|
"type": "push",
|
||||||
"topic": event.get("topic"),
|
"topic": event.get("topic"),
|
||||||
"data": event.get("data", {}),
|
"data": event.get("data", {}),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Djarea Push - Server-initiated messages to clients.
|
mizan Push - Server-initiated messages to clients.
|
||||||
|
|
||||||
Simple API for pushing data to subscribed WebSocket connections.
|
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,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
@@ -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 (
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).
|
Pydantic→OpenAPI conversion (handling $refs, nested types, etc.).
|
||||||
@@ -198,9 +203,9 @@ def generate_openapi_schema() -> dict[str, Any]:
|
|||||||
# This is NOT exposed as an HTTP endpoint - purely for leveraging Ninja's
|
# 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"],
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.",
|
||||||
@@ -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]
|
||||||
@@ -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(
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
3
django/src/mizan/shapes/__init__.py
Normal file
3
django/src/mizan/shapes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from mizan.shapes.core import Diff, NestedDiff, Shape
|
||||||
|
|
||||||
|
__all__ = ["Diff", "NestedDiff", "Shape"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Authentication Tests for Djarea Server Functions
|
Authentication Tests for mizan Server Functions
|
||||||
|
|
||||||
Tests all combinations of:
|
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("/")
|
||||||
@@ -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(
|
||||||
|
User(
|
||||||
email=f"bench{i}@example.com",
|
email=f"bench{i}@example.com",
|
||||||
is_active=i % 10 != 0, # 90% active
|
is_active=i % 10 != 0, # 90% active
|
||||||
is_staff=i < 5, # 5 staff
|
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(
|
||||||
|
User(
|
||||||
email=f"bench{i}@example.com",
|
email=f"bench{i}@example.com",
|
||||||
is_active=i % 10 != 0,
|
is_active=i % 10 != 0,
|
||||||
is_staff=i < 5,
|
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)
|
||||||
@@ -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",
|
"id": "test-123",
|
||||||
"fn": "rpc_echo",
|
"fn": "rpc_echo",
|
||||||
"args": {"message": "Hello"},
|
"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",
|
"id": "add-123",
|
||||||
"fn": "rpc_add",
|
"fn": "rpc_add",
|
||||||
"args": {"a": 5, "b": 3},
|
"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",
|
"id": "test-456",
|
||||||
"fn": "nonexistent_function",
|
"fn": "nonexistent_function",
|
||||||
"args": {},
|
"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",
|
"id": "test-789",
|
||||||
"fn": "rpc_echo",
|
"fn": "rpc_echo",
|
||||||
"args": {}, # Missing required 'message' field
|
"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",
|
"fn": "rpc_echo",
|
||||||
"args": {"message": "test"},
|
"args": {"message": "test"},
|
||||||
# Missing 'id'
|
# 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",
|
"id": "test-abc",
|
||||||
"args": {"message": "test"},
|
"args": {"message": "test"},
|
||||||
# Missing 'fn'
|
# 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",
|
"id": "auth-test",
|
||||||
"fn": "rpc_auth_required",
|
"fn": "rpc_auth_required",
|
||||||
"args": {},
|
"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 = {
|
||||||
@@ -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
|
||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
@@ -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):
|
||||||
@@ -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")
|
||||||
@@ -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",
|
"id": "123",
|
||||||
"fn": "ws_echo",
|
"fn": "ws_echo",
|
||||||
"args": {"message": 12345} # Should be string
|
"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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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})\"",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user