Class-Based Views (djangors-views)
djangors-views provides server-rendered generic class-based views — Django’s
django.views.generic equivalent in Djangors. It ships five generic views
(ListView, DetailView, CreateView, UpdateView, DeleteView) that turn a
#[derive(Model)] type into CRUD pages rendered through the
template engine.
When to reach for it
Reach for djangors-views when you are building a server-rendered HTML
application — pages that return text/html rendered from templates, where
forms submit via application/x-www-form-urlencoded and the browser follows a
redirect on success.
It is not the right tool for JSON APIs. For a JSON/fetch()-based API over
the same model use djangors-rest’s ViewSet (see the
REST Framework guide), which emits list/create/
retrieve/update/destroy routes and serialized JSON bodies instead of
rendered HTML.
The five views
| View | Signature (generic bound) | Renders / redirects |
|---|---|---|
ListView<M> | M: Model + FromRow | Renders object_list |
DetailView<M> | M: Model + FromRow | Renders object (looked up by pk) |
CreateView<M> | M: Model + FromRow + ModelForm | GET renders a blank form; POST inserts + redirects |
UpdateView<M> | M: Model + FromRow + ModelForm | GET pre-fills a form; POST updates by pk + redirects |
DeleteView<M> | M: Model + FromRow | GET renders a confirm page; POST deletes by pk + redirects |
Every view is an async fn that takes (req: Request, params: PathParams, config: &ViewSetConfig) and returns Result<Response, DjangorsError>.
Configuration (ViewSetConfig)
Each view needs a ViewSetConfig
that tells it which TemplateEngine to render with, which template to use, and
where to redirect after a successful write:
use std::sync::LazyLock;
use djangors_template::TemplateEngine;
use djangors_views::ViewSetConfig;
fn main() {
static TEMPLATES: LazyLock<TemplateEngine> = LazyLock::new(|| {
TemplateEngine::new(vec!["templates".into()]).expect("load templates")
});
// The engine is a `&'a TemplateEngine` inside the config, so it must outlive
// every handler. A `static` (via `LazyLock`) is the usual way to satisfy that.
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_list.html",
success_url: "/polls/",
};
let _ = &config;
}
Registering all five views on a Router
Router handlers are Fn(Request, PathParams) -> Future; the view functions
take the config as an extra argument, so wrap each one in a closure that builds
(or references) its config. Because the config borrows the engine, building the
config inside the closure from a 'static engine keeps the closures 'static:
#![allow(unused)]
fn main() {
use std::sync::LazyLock;
use djangors_core::Router;
use djangors_template::TemplateEngine;
use djangors_views::{CreateView, DeleteView, DetailView, ListView, UpdateView, ViewSetConfig};
use polls::models::{Choice, Question};
static TEMPLATES: LazyLock<TemplateEngine> = LazyLock::new(|| {
TemplateEngine::new(vec!["templates".into()]).expect("load templates")
});
fn question_routes() -> Router {
Router::new()
.get("/polls/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_list.html",
success_url: "/polls/",
};
ListView::<Question>::list(req, params, &config).await
})
.post("/polls/new/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_form.html",
success_url: "/polls/",
};
CreateView::<Question>::create(req, params, &config).await
})
.get("/polls/{pk}/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_detail.html",
success_url: "/polls/",
};
DetailView::<Question>::detail(req, params, &config).await
})
.post("/polls/{pk}/edit/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_form.html",
success_url: "/polls/",
};
UpdateView::<Question>::update(req, params, &config).await
})
.post("/polls/{pk}/delete/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_confirm_delete.html",
success_url: "/polls/",
};
DeleteView::<Question>::delete(req, params, &config).await
})
.post("/choices/new/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/choice_form.html",
success_url: "/polls/",
};
CreateView::<Choice>::create(req, params, &config).await
})
}
}
Important
The
{pk}path param is read viaPathParams::get_as::<i64>("pk"). The route must expose a segment namedpk, so name it{pk}(or{pk:i64}) in the path pattern — not{id}/{question_id}.
Template context variables
Each view renders the configured template with a fixed set of context variables:
| View | object_list | object | fields | form | errors |
|---|---|---|---|---|---|
ListView | all rows | — | — | — | — |
DetailView | — | one row | — | — | — |
CreateView (GET) | — | — | field names | — | {} empty |
CreateView (POST error) | — | — | field names | submitted data | per-field + __all__ |
UpdateView (GET) | — | one row | field names | — | {} empty |
UpdateView (POST error) | — | one row | — | submitted data | per-field + __all__ |
DeleteView (GET) | — | one row | — | — | — |
object_list— an array of row objects, each keyed by field name (e.g.{{ o.question_text }}).object— a single row object keyed by field name (e.g.{{ object.question_text }}).fields— the model’s field names (fromModel::field_names()), useful for rendering an empty form’s inputs.form— the submittedHashMap<String, String>on a validation error, so you can re-populate the inputs (value="{{ form['question_text'] }}").errors— an object mapping each invalid field name to its error message, plus a__all__key for non-field errors. On the initial GET it is an empty object{}.
The HTML form contract (CreateView / UpdateView)
CreateView and UpdateView expect an application/x-www-form-urlencoded
POST body whose field names match the model’s field names. The model must
implement djangors_orm::ModelForm — which #[derive(Model)] provides by
generating validate_form(), from_cleaned_form(), and apply_cleaned_form().
CreateView(POST) — submitted data is parsed with theFormextractor, thenM::validate_form(&data)runs. On success the cleaned values build a new instance viafrom_cleaned_form(), the row is inserted, and the response redirects tosuccess_url. On failure the form template is re-rendered withfields,form, anderrors.UpdateView(POST) — the row forpkis fetched, thenM::validate_form(&data)runs. On successapply_cleaned_form()mutates the fetched instance, all non-primary-key fields are written back, and the response redirects tosuccess_url. On failure the form template is re-rendered withobject,form, anderrors.
A minimal create form template for Question:
<form method="post" action="/polls/new/">
<label>Question text
<input name="question_text" value="{{ form['question_text'] }}">
</label>
<label>Published
<input name="pub_date" type="datetime-local" value="{{ form['pub_date'] }}">
</label>
{% if errors['question_text'] %}<span>{{ errors['question_text'] }}</span>{% endif %}
<button type="submit">Create</button>
</form>
Prerequisites
- The model must be a
#[derive(Model)]type.ListView/DetailView/DeleteViewneedM: Model + FromRow(both provided by#[derive(Model)]).CreateView/UpdateViewadditionally needM: ModelForm— also generated by#[derive(Model)].
- A
djangors_orm::djangors_db::Databasemust be in the request state, because every view callsreq.require_state::<Database>()to fetch rows. Attach it withRouter::with_state(db)(orRequest::with_state). - A
TemplateEnginemust be passed viaconfig.engine. The views only read the engine from the config — the engine is not pulled from request state, so you are free to keep it anywhere (astatic, app state, etc.) as long as it outlives the handlers. - The configured
template_namemust exist in one of the engine’s search directories, or rendering fails with an internal error.
Note
The
Databasecomes from request state, but theTemplateEnginecomes exclusively from the config. These are two separate mechanisms — don’t assume the engine is discoverable from the request.
Limitations
- No authentication or permissions built in. Views return
Responsedirectly and perform no login/permission checks. Wrap them in your own middleware, or check the current user inside a wrapper handler, usingdjangors-auth(see the Authentication guide). - No pagination.
ListViewfetches every row withQuerySet::all()and renders them all. There is no page-size or pagination control. ListView/DetailVieware render-only. They do not branch on the HTTP method — they always render. OnlyCreateView/UpdateView/DeleteViewdistinguish GET from POST (anything that is not GET is treated as POST).- URL-encoded forms only.
CreateView/UpdateViewparse the body withForm::<HashMap<String, String>>::from_request, i.e. multipart or file uploads are not handled. - Integer primary keys. Records are looked up and deleted by an
i64pk(params.get_as::<i64>("pk")); a non-integerpkis aBadRequest. - The
pkname is fixed. Detail/update/delete look up thepkpath parameter by name, so your route must use{pk}.