API keys, audit log, webhooks, uploads and jobs¶
The optional Django apps and adapters in ninja_devx.contrib. The apps
(apikeys, audit, webhooks, uploads, jobs) need INSTALLED_APPS and a
migration; redis_throttle and nplusone are adapters with no app of their own.
See API keys, Audit log,
Webhooks, Uploads,
Jobs, Operations and
N+1 detection and devx_doctor.
API keys (ninja_devx.contrib.apikeys)¶
create_api_key()¶
def create_api_key(user: object, name: str, *, scopes: Iterable[str] = (), expires_at: datetime | None = None, rate_limit: str = '') -> tuple[APIKey, str]: ...
Create a key and return it with its raw value, which is never stored or shown again.
| Parameter | Type | Default | Description |
|---|---|---|---|
user |
object |
— | The key's owner; requests authenticated with it act as this user. |
name |
str |
— | A label for humans ("CI deploys"). |
scopes |
Iterable[str] |
() |
What the key may do ("orders:read", "orders:*", "*"). |
expires_at |
datetime \| None |
None |
When the key stops working (None: never). |
rate_limit |
str |
'' |
This key's rate for APIKeyRateThrottle ("1000/hour"; empty: the throttle's default). |
revoke_api_key()¶
Stop accepting key (kept for audit).
| Parameter | Type | Default | Description |
|---|---|---|---|
key |
APIKey |
— | The key to revoke. |
Authentication and scopes¶
| Class | Arguments | Description |
|---|---|---|
APIKeyAuth |
— | X-API-Key: ndx_...; request.auth is the key's user. |
APIKeyBearer |
— | Authorization: Bearer ndx_.... |
RequiresScope |
scope: str |
Operation metadata: meta=(RequiresScope("orders:write"),). |
HasScopes |
allow_unscoped: bool = False |
Every RequiresScope of the operation must be granted to the request's API key. |
APIKeyRateThrottle |
— | Per API key: the key's own rate_limit, else rate; other requests pass. |
scope_allows()¶
Whether granted covers required: exact, "orders:*" or "*".
| Parameter | Type | Default | Description |
|---|---|---|---|
granted |
Sequence[str] |
— | Scopes of the key. |
required |
str |
— | The scope an operation needs ("orders:write"). |
APIKeyController¶
GET / my keys, POST / create one (raw key in the response), DELETE /{id}.
| Name | Type | Default | Description |
|---|---|---|---|
grantable_scopes |
Sequence[str] \| None |
None |
Scopes users may put on their keys (None: any). |
Audit log (ninja_devx.contrib.audit)¶
AuditMixin¶
Record creates, updates and deletes of a model controller (bulk ones too).
| Name | Type | Default | Description |
|---|---|---|---|
audit_privacy |
AuditPrivacy |
AuditPrivacy() |
Storage allowlists, credential redaction and safe object labels. |
audit_fields |
Sequence[str] \| None |
None |
Fields kept in snapshots and diffs (None: every concrete field). |
audit_exclude |
Sequence[str] |
('password',) |
Fields never recorded. |
audit_redact |
Sequence[str] |
() |
Fields recorded as "***": a change is visible, the value is not. |
AuditPrivacy¶
Allowlist business fields and redact credential values before persistence.
| Name | Type | Default | Description |
|---|---|---|---|
fields |
tuple[str, ...] \| None |
None |
Allowlist of change field names to keep (None: keep every field not excluded). |
exclude |
tuple[str, ...] |
('password', 'hashed_secret', 'private_key') |
Change field names dropped entirely, never stored even redacted. |
redact |
tuple[str, ...] |
('secret', 'token', 'access_token', 'refresh_token', 'api_key', 'authorization') |
Change field names stored as "***" instead of their value. |
metadata_fields |
tuple[str, ...] \| None |
None |
Allowlist of metadata keys to keep (None: keep every key). |
object_repr |
bool |
False |
Opt in to calling the model's potentially sensitive __str__. |
schema |
type[object] \| None |
None |
A schema (ninja_devx.serialization.privacy.Sensitive-annotated) whose marked fields are redacted like redact names. |
record()¶
def record(request: HttpRequest | None, action: str, obj: models.Model | None = None, *, object_pk: object = None, changes: Mapping[str, object] | None = None, metadata: Mapping[str, object] | None = None, using: str | None = None, privacy: AuditPrivacy | None = None) -> AuditEntry: ...
Write an audit entry for action on obj, attributed to request's user.
| Parameter | Type | Default | Description |
|---|---|---|---|
request |
HttpRequest \| None |
— | The request (actor, request id, method, path, IP); None for jobs. |
action |
str |
— | A short verb: "create", "update", "delete", "export"... |
obj |
models.Model \| None |
None |
The object acted on, if any. |
object_pk |
object |
None |
The key when obj no longer has one (after a delete). |
changes |
Mapping[str, object] \| None |
None |
{field: [old, new]}. |
metadata |
Mapping[str, object] \| None |
None |
Anything else worth keeping (JSON-serializable). |
using |
str \| None |
None |
Database alias; defaults to the object database, then the audit write router. |
privacy |
AuditPrivacy \| None |
None |
Storage allowlists/redaction policy; default protects common credential fields. |
snapshot()¶
def snapshot(instance: models.Model, *, fields: Sequence[str] | None = None, exclude: Sequence[str] = (), redact: Sequence[str] = ()) -> dict[str, object]: ...
JSON-safe values of instance's concrete fields (foreign keys as their key).
| Parameter | Type | Default | Description |
|---|---|---|---|
instance |
models.Model |
— | The model instance. |
fields |
Sequence[str] \| None |
None |
Field names to keep (None: all concrete fields). |
exclude |
Sequence[str] |
() |
Field names to leave out. |
redact |
Sequence[str] |
() |
Field names whose values become "***". |
diff()¶
{field: [old, new]} for the fields whose value changed.
| Parameter | Type | Default | Description |
|---|---|---|---|
before |
Mapping[str, object] |
— | A snapshot taken before the change. |
after |
Mapping[str, object] |
— | A snapshot taken after it. |
redact()¶
def redact(changes: Mapping[str, list[object]], fields: Sequence[str]) -> dict[str, list[object]]: ...
Replace the values of fields by "***" (a change stays visible).
| Parameter | Type | Default | Description |
|---|---|---|---|
changes |
Mapping[str, list[object]] |
— | {field: [old, new]}. |
fields |
Sequence[str] |
— | Field names to hide. |
AuditFilters¶
Query parameters of GET / on AuditLogController.
| Name | Type | Default | Description |
|---|---|---|---|
action |
str \| None |
None |
create, update, delete or a custom action. |
actor_id |
int \| str \| UUID \| None |
None |
Entries by this user. |
model |
str \| None |
Field(None, description='app_label.model.') |
Entries about this model: app_label.model. |
object_pk |
str \| None |
None |
Entries about this object (with model). |
request_id |
str \| None |
None |
Everything recorded during one request. |
since |
Annotated[datetime \| None, FilterLookup('created__gte')] |
None |
Entries at or after this time. |
until |
Annotated[datetime \| None, FilterLookup('created__lt')] |
None |
Entries before this time. |
Webhooks (ninja_devx.contrib.webhooks)¶
publish()¶
def publish(event_type: str, payload: Mapping[str, object], *, owner: Model | None = None, tenant_key: str = '', broadcast: bool = False, using: str | None = None, queue: TaskQueue | None = None, task: Enqueueable[[str, str]] | None = None) -> OutboxEvent: ...
Store an event and a pending delivery per subscribed endpoint, in the current transaction (DjangoJSONEncoder serializes dates, decimals and UUIDs).
| Parameter | Type | Default | Description |
|---|---|---|---|
event_type |
str |
— | Dotted event name ("order.created"); endpoints subscribe to it. |
payload |
Mapping[str, object] |
— | The event data, sent as data in the request body. |
owner |
Model \| None |
None |
Only this user's endpoints, within tenant_key. |
tenant_key |
str |
'' |
Server-controlled tenant scope; empty means personal endpoints. |
broadcast |
bool |
False |
Explicitly send to every subscribed endpoint; cannot combine with a target. |
using |
str \| None |
None |
Database alias shared by business writes and the outbox. |
queue |
TaskQueue \| None |
None |
Where to enqueue the delivery (OnCommitTaskQueue()). |
task |
Enqueueable[[str, str]] \| None |
None |
The task receiving the event id and database alias. Default: deliver_event_task (Django 6.0+); pass your Celery/RQ wrapper otherwise. |
deliver_due()¶
def deliver_due(*, limit: int = 100, transport: Transport | None = None, timeout: float = 10.0, schedule: Sequence[timedelta] = RETRY_SCHEDULE, disable_after: timedelta | None = timedelta(days=5), url_policy: URLPolicy | None = None, event_id: str | None = None, now: datetime | None = None, using: str | None = None) -> DeliveryReport: ...
Send up to limit due deliveries. Safe to run from several workers (each row uses a conditional claim and a token-checked finalization).
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
int |
100 |
Deliveries handled in this call. |
transport |
Transport \| None |
None |
How requests are sent (default SafeHTTPTransport(url_policy)). |
timeout |
float |
10.0 |
Seconds per request. |
schedule |
Sequence[timedelta] |
RETRY_SCHEDULE |
Delays before each retry; the delivery fails after the last one. |
disable_after |
timedelta \| None |
timedelta(days=5) |
Disable an endpoint failing continuously for this long. |
url_policy |
URLPolicy \| None |
None |
Where the default transport may send requests. |
event_id |
str \| None |
None |
Only deliveries of this event. |
now |
datetime \| None |
None |
The current time (tests). |
using |
str \| None |
None |
Database containing the outbox. |
deliver_event()¶
Send the due deliveries of one event now (what enqueued tasks run).
| Parameter | Type | Default | Description |
|---|---|---|---|
event_id |
str |
— | The OutboxEvent primary key. |
using |
str \| None |
None |
Database alias containing the outbox event. |
event_matches()¶
"*", an exact type, or a "order.*" prefix.
| Parameter | Type | Default | Description |
|---|---|---|---|
patterns |
Sequence[str] |
— | An endpoint's subscriptions. |
event_type |
str |
— | The published event type. |
verify_signature()¶
def verify_signature(secrets_: str | list[str], headers: Mapping[str, str], body: bytes, *, tolerance: int = 300, now: float | None = None) -> None: ...
Raise InvalidSignature unless body was signed by one of secrets_.
| Parameter | Type | Default | Description |
|---|---|---|---|
secrets_ |
str \| list[str] |
— | The endpoint secret, or several during a rotation. |
headers |
Mapping[str, str] |
— | Request headers (any case); request.headers works. |
body |
bytes |
— | The raw request body (request.body), not re-serialized JSON. |
tolerance |
int |
300 |
Maximum age of the timestamp, in seconds. |
now |
float \| None |
None |
The current Unix time (tests). |
signature_headers()¶
def signature_headers(secret: str, message_id: str, body: bytes, *, timestamp: int | None = None) -> dict[str, str]: ...
The webhook-id, webhook-timestamp and webhook-signature headers.
| Parameter | Type | Default | Description |
|---|---|---|---|
secret |
str |
— | The endpoint secret (whsec_...). |
message_id |
str |
— | Unique message id, the same across retries. |
body |
bytes |
— | The exact bytes that are sent. |
timestamp |
int \| None |
None |
Unix time of the attempt (default: now). |
generate_secret()¶
A new signing secret: whsec_<base64 of 24 random bytes>.
URLPolicy¶
Where webhooks may be sent.
| Name | Type | Default | Description |
|---|---|---|---|
allow_http |
bool |
False |
Accept http:// URLs (default: HTTPS only). |
allow_private_networks |
bool |
False |
Accept loopback, private, link-local and other non-public addresses (development). |
check_url()¶
Raise UnsafeURL when url breaks policy (static checks, no DNS).
| Parameter | Type | Default | Description |
|---|---|---|---|
url |
str |
— | The endpoint URL. |
policy |
URLPolicy \| None |
None |
What is allowed. |
SafeHTTPTransport¶
The default webhook transport: urllib, no redirects, no environment proxies, and policy enforced on the URL and on the connected address.
| Name | Type | Default | Description |
|---|---|---|---|
policy |
URLPolicy |
URLPolicy() |
Where requests may go. |
encrypt_secret()¶
The value to store for raw: encrypted when keys are configured.
| Parameter | Type | Default | Description |
|---|---|---|---|
raw |
str |
— | The signing secret (whsec_...). |
decrypt_secret()¶
The raw secret from a stored value (plain values are returned as they are).
| Parameter | Type | Default | Description |
|---|---|---|---|
stored |
str |
— | The database value. |
reencrypt_secret()¶
stored encrypted with the first key (plain values get encrypted).
| Parameter | Type | Default | Description |
|---|---|---|---|
stored |
str |
— | The database value. |
WebhookEndpointController¶
The user's endpoints: CRUD, deliveries, retry, ping and secret rotation.
| Name | Type | Default | Description |
|---|---|---|---|
url_policy |
URLPolicy |
URLPolicy() |
Which URLs may be registered (HTTPS, public addresses by default). Pass the same policy to the delivery worker. |
available_events |
Sequence[str] \| None |
None |
Event types clients may subscribe to (None: any); patterns must match one. |
Uploads (ninja_devx.contrib.uploads)¶
UploadController¶
POST / signs an upload, POST /complete verifies it. Set signer.
| Name | Type | Default | Description |
|---|---|---|---|
signer |
UploadSigner \| None |
None |
Storage backend (S3Signer, FakeSigner or your own). |
policy |
UploadPolicy |
UploadPolicy() |
Allowed types, size and expiry. |
upload_database |
str |
'default' |
Alias containing core UploadRecord rows (run migrations on it). |
key_prefix |
str |
'' |
Added before <user id>/<uuid>/<filename> (after the signer's own prefix). |
UploadPolicy¶
What may be uploaded.
| Name | Type | Default | Description |
|---|---|---|---|
content_types |
Sequence[str] |
('*/*',) |
Allowed media types; "image/*" allows a family. |
max_bytes |
int |
10 * 1024 * 1024 |
Largest accepted size. |
expires_in |
timedelta |
timedelta(minutes=10) |
How long the signed form stays valid. |
cleanup_grace |
timedelta |
timedelta(minutes=15) |
Grace after form expiry, allowing in-flight uploads to finish before cleanup. |
require_checksum |
bool |
False |
Require a base64 SHA-256 digest in the signed request and stored object. |
require_version |
bool |
False |
Require storage versioning; consumers must read the returned version_id. |
S3Signer¶
Presigned POST uploads to S3 (or MinIO, R2...) with boto3 (ninja-devx[s3]).
| Name | Type | Default | Description |
|---|---|---|---|
bucket |
str |
— | Bucket name. |
prefix |
str |
'uploads/' |
Key prefix for every upload of this signer. |
client |
S3Client \| None |
None |
A boto3 S3 client (default: boto3.client("s3"), created on first use). |
Test signer¶
| Class | Arguments | Description |
|---|---|---|
FakeSigner |
url: str = 'https://storage.test/upload', objects: dict[str, StoredObject] = field(default_factory=dict[str, StoredObject]), signed: list[str] = field(default_factory=list[str]) |
In-memory signer for tests: signer.store(key, size, content_type) simulates the client's upload. |
safe_filename()¶
ASCII, no directories, no leading dots: "../Résumé 2024.pdf" → "Resume_2024.pdf".
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str |
— | The client's file name. |
max_length |
int |
100 |
Longest result, extension included. |
Jobs (ninja_devx.contrib.jobs)¶
JobsController: GET / (the caller's jobs, paginated), GET /{id} and
POST /{id}/cancel. Mount next to the endpoints that call start_job.
Maintain with manage.py devx_jobs prune/retry (see
Commands).
JobOut¶
| Name | Type | Default | Description |
|---|---|---|---|
id |
UUID |
— | Primary key, also the value start_job/accepted return in the Location. |
name |
str |
— | Recorded label, shown in the API and admin. |
status |
str |
— | One of Job.Status: queued, running, succeeded, failed, cancelled. |
progress |
int |
— | Last value reported through JobContext.set_progress (0 to 100). |
result |
dict[str, object] |
— | The function's return value, once succeeded. |
error |
str |
— | The failure message, once failed. |
attempts |
int |
— | Times the job has started running, including retries. |
created |
datetime |
— | When start_job created the row. |
started |
datetime \| None |
— | When the job most recently started running. |
finished |
datetime \| None |
— | When the job reached a terminal status. |
start_job()¶
def start_job(request: HttpRequest, name: str, function: JobFunction | str, *args: object, queue: TaskQueue | None = None, tenant: str = '', **kwargs: object) -> Job: ...
Create a queued job for the caller and enqueue its run.
| Parameter | Type | Default | Description |
|---|---|---|---|
request |
HttpRequest |
— | The current request; created_by is the authenticated user (401 without one). |
name |
str |
— | Recorded on the job; shown in the API and admin. |
function |
JobFunction \| str |
— | A @job-registered function, its registered name, or a dotted import path. A plain function is recorded by module.qualname. |
*args |
object |
— | Positional arguments passed to the function after its JobContext. |
queue |
TaskQueue \| None |
None |
Where run_job is enqueued. Default: a TaskQueue singleton from the request's container, else OnCommitTaskQueue(). |
tenant |
str |
'' |
Recorded on the job (server-controlled; not read from the request body). |
**kwargs |
object |
— | Keyword arguments passed to the function. |
run_job()¶
def run_job(job_id: str, target: str, retry: int = 0, /, *args: object, **kwargs: object) -> None: ...
Run a job's function inside a JobContext, storing the outcome on its row.
| Parameter | Type | Default | Description |
|---|---|---|---|
job_id |
str |
— | Primary key of the Job row (also passed to the JobContext). |
target |
str |
— | A name registered with @job, or a dotted import path. |
retry |
int |
0 |
Extra attempts on failure, made in this same call before giving up. |
*args |
object |
— | Positional arguments passed to the function after its JobContext. |
**kwargs |
object |
— | Keyword arguments passed to the function. |
accepted()¶
A 202 response pointing at the job: a Location header and a small body.
| Parameter | Type | Default | Description |
|---|---|---|---|
request |
HttpRequest |
— | The current request, used to build an absolute URL. |
row |
Job |
— | The job just started. |
prefix |
str |
'/jobs' |
Where JobsController is mounted. |
job()¶
Register a function so workers resolve it by name instead of pickling it.
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str |
— | The name start_job and run_job use to find the function again. |
resolve_job()¶
The function registered, or importable, as target.
| Parameter | Type | Default | Description |
|---|---|---|---|
target |
str |
— | A name passed to @job, or a dotted import path. |
JobContext()¶
Given to a job function: progress reporting and cooperative cancellation.
| Parameter | Type | Default | Description |
|---|---|---|---|
job_id |
str |
— | Primary key of the Job row this context reports on. |
Redis throttle storage (ninja_devx.contrib.redis_throttle)¶
Throttle storage¶
| Class | Arguments | Description |
|---|---|---|
RedisThrottleStorage |
client: _RedisClient \| None = None, *, url: str = 'redis://localhost:6379/0' |
ThrottleStorage counting fixed windows on Redis with one atomic round trip. |
N+1 detection (ninja_devx.contrib.nplusone)¶
An adapter over django-zeal
(the zeal extra): add NPlusOnePlugin()/NPlusOneMiddleware() so every
request runs inside zeal's tracking context, and its errors are rewritten
to name the controller and the fix. A no-op when django-zeal is not
installed.
N+1 middleware¶
| Class | Arguments | Description |
|---|---|---|
NPlusOneMiddleware |
— | Runs the request inside zeal's tracking context and explains its errors. |
NPlusOnePlugin |
— | Adds NPlusOneMiddleware to every operation of the API. |
explain_n_plus_one()¶
Rewrite a zeal NPlusOneError message to name controller and suggest a fix.
| Parameter | Type | Default | Description |
|---|---|---|---|
message |
str |
— | str(exc) from the NPlusOneError zeal raised. |
controller |
str \| None |
None |
Qualified name of the controller handling the request, if known. |
zeal_installed()¶
Whether django-zeal is importable.
zeal_strict()¶
zeal.zeal_context() forced to raise, regardless of settings.ZEAL_RAISE.
django-rules (ninja_devx.contrib.rules)¶
Rules from django-rules (the
rules extra) as permissions, checked on the request and on loaded
objects (see Permissions).
Rule permissions¶
| Class | Arguments | Description |
|---|---|---|
HasRule |
rule: str \| Predicate \| Callable[..., object] |
Requires a rule: HasRule("blog.change_post") or HasRule(is_author). |
Task queue adapters (ninja_devx.contrib.tasks)¶
Task queues¶
| Class | Arguments | Description |
|---|---|---|
CeleryTaskQueue |
DEFAULT_METHODS: tuple[str, ...] = ('delay',) |
Celery: task.delay(*args, **kwargs). |
DramatiqTaskQueue |
DEFAULT_METHODS: tuple[str, ...] = ('send',) |
Dramatiq: actor.send(*args, **kwargs). |
RQTaskQueue |
queue: object |
RQ: queue.enqueue(function, *args, **kwargs). |
TaskiqTaskQueue |
DEFAULT_METHODS: tuple[str, ...] = ('kiq',) |
Taskiq: task.kiq(*args, **kwargs). |
TemporalTaskQueue |
client: object |
Temporal: client.start_workflow(workflow, *args, **kwargs). |
FastStreamTaskQueue |
broker: object, *, queue: str = '' |
FastStream: publish {"task", "args", "kwargs"} through a broker. |
FrameworkTaskQueue |
*, queue: object \| None = None, client: object \| None = None, methods: Sequence[str] \| None = None |
Defer work using a task framework's callable conventions. |
DeferredTaskQueue |
defer: Defer |
TaskQueue over any defer(function, args, kwargs) callable. |