🧱 Form sets and layouts
By default, the edit page shows the model's fields in the order they are
declared. Set edit_main and edit_sidebar on the studio to choose the
fields and lay them out, with the building blocks in content_studio.form.
# blog/admin.py
from content_studio import register
from content_studio.admin import ModelStudio
from content_studio.form import Field, FormSet
from .models import Article
@register(Article)
class ArticleStudio(ModelStudio):
edit_main = [
FormSet(
title="Content",
description="The article itself",
fields=[
"title",
Field("status", col_span=1),
Field("body", col_span=3),
],
),
]
edit_sidebar = ["published_at", "featured"]
Building blocks
Field(name, col_span=1, label=None, readonly=False, widget=None): one model field. A string is short forField(name).FieldLayout(fields=[...], columns=1): a group of fields shown incolumnscolumns.FormSet(title, description, fields): a titled block of fields, the main way to group the edit page.FormSetGroup(label, formsets=[...]): a group of form sets.
You do not have to wrap everything: a list of fields becomes a form set, and a list of form sets becomes a group.
Every required model field must be in edit_main or edit_sidebar. Like
the classic Django admin's form, the edit page only saves the fields it
shows, so a required field that is left out, also through exclude, makes
saving fail. System check
content_studio.W001 reports these fields. Fields with a default, nullable
or blank fields, and the fields Content Studio fills in itself (the audit
stamps and the tenant), are never required.
Sidebar
edit_sidebar works the same way and shows its fields in the narrow column
next to the main area. A field with readonly=True shows its value without
an input.
To edit one field with another widget, pass the widget class:
Field("body", widget=widgets.TextAreaWidget). See
Widgets for the widgets you can use.
Read-only fields
Like the classic Django admin, a studio can list fields in
readonly_fields. The edit page shows their values without an input, and
saving never changes them. readonly=True on a Field does the same for
that one place on the edit page.
To decide per editor or per object, override get_readonly_fields. It
receives the request and the object, which is None on the create form.
This keeps an article's title editable until the article is published:
# blog/admin.py
from content_studio import register
from content_studio.admin import ModelStudio
from .models import Article
@register(Article)
class ArticleStudio(ModelStudio):
def get_readonly_fields(self, request, obj=None):
if obj is not None and obj.status == Article.Status.PUBLISHED:
return ["title"]
return []
Inlines take readonly_fields and get_readonly_fields too. A required
field in readonly_fields can never be filled in, so creating objects fails;
system check content_studio.W002 reports it.
Prepopulated fields
Like the classic Django admin, prepopulated_fields fills in a field from
other fields while you create an object: typically a slug from the title.
Map the field to the fields it is made from:
# blog/models.py
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
def __str__(self):
return self.title
# blog/admin.py
from content_studio import register
from content_studio.admin import ModelStudio
from .models import Article
@register(Article)
class ArticleStudio(ModelStudio):
prepopulated_fields = {"slug": ("title",)}
As you type the title, the slug follows it: accents are dropped, spaces
become hyphens and the result is cut to the field's max_length, as
Django's slugify does. Once you edit the slug yourself, it stops
following. Existing objects are never prepopulated, so a published URL does
not change when you rename an article.
To decide per editor, override get_prepopulated_fields(request, obj=None).
Inlines take prepopulated_fields too. A field that cannot hold text (a
DateTimeField, ForeignKey, OneToOneField or ManyToManyField), a
field in readonly_fields and names that are not fields are reported by
system check content_studio.E016.
A field that the model fills in itself when it is saved, and that is not
editable (editable=False), needs no prepopulated_fields: the edit page
shows its value read-only.
Unique fields
For every editable field with unique=True, such as the slug above, the
edit page checks while you type whether another object already uses the
value, and shows a warning under the field that names that object. The
check works with any model field and needs no configuration.
The warning does not stop you from saving: Django's uniqueness validation still decides, and reports the error when you save. Objects in the trash count, because they still hold the value. The warning names the other object only if you may view it.
On a model scoped to a tenant, only objects of the current tenant are compared.
Combinations of fields
The same check covers combinations of fields: Meta.unique_together and
every UniqueConstraint in Meta.constraints with more than one field.
When the combination of values in the form matches another object, the
warning shows under the field of the combination you edited last and names
the other fields. Before you edit any of them, it shows under the
combination's last field on the form.
A review is a fragment of blog/models.py, next to the Article model
above: each author may publish one review per article.
# blog/models.py
class Review(models.Model):
article = models.ForeignKey(Article, on_delete=models.CASCADE)
author = models.CharField(max_length=100)
text = models.TextField()
published = models.BooleanField(default=False)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["article", "author"],
condition=models.Q(published=True),
name="one_published_review_per_author",
),
]
def __str__(self):
return f"{self.author} on {self.article}"
The edit page warns when the author already has a published review of the
same article, and only while the review you are editing is marked as
published: the constraint's condition applies to both reviews. A field of
the combination that is not on the form takes the value stored on the object
you are editing; a new object is only checked when every field of the
combination is on the form. A field of the condition that is not on the
form takes the stored value, or on a new object its default.
The check only reports what the database would reject:
- Empty values in the combination never clash. The exception is a null
value under a constraint with
nulls_distinct=False, on a database that supports it (PostgreSQL 15 or later). - The tenant field is left out of the combination, because only objects
of the current tenant are compared. A
UniqueConstrainton a field and the tenant field makes the field unique per tenant, and the warning covers it. - Functional constraints (expressions such as
Lower("title")) and deferrable constraints are not checked. Saving still reports their conflicts.
Components
Besides fields, the edit page can show components: links, buttons that run your code, and the object's activity log.
# blog/admin.py
from content_studio import register
from content_studio.admin import ModelStudio
from content_studio.form import ActivityLog, ButtonLink
from .models import Article
class ViewOnSiteButton(ButtonLink):
label = "View on site"
icon = "ph-bold ph-arrow-square-out"
def get_url(self, obj, request):
return obj.get_absolute_url()
@register(Article)
class ArticleStudio(ModelStudio):
edit_sidebar = ["published_at", "author", ViewOnSiteButton(), ActivityLog()]
Link: a text link that opens the URLget_url(obj, request)returns.ButtonLink: the same as a button, with an optionalicon. Setcopy = Trueto add a button that copies the URL.Button: a button that runs your code on the object; see below.ActivityLog: the changes made to the object, newest first, under the title "Activity" or thetitleyou pass.
Only these components are supported; other component types are not. Links and buttons appear once the object is saved.
Buttons
A Button runs its handle(obj, request) method for the object being
edited. What it returns is shown to the editor: a string as a notification,
or a ButtonResult with a message and a value. The value appears once
in a dialog with a copy button, for something the editor must copy, such as
a generated key.
# blog/admin.py
from content_studio import register
from content_studio.admin import ModelStudio
from content_studio.form import ActivityLog, Button
from content_studio.signals import record_change
from .models import Article
class PublishButton(Button):
label = "Publish"
icon = "ph-bold ph-paper-plane-tilt"
confirm = "Publish this article?"
allowed_permissions = ["change"]
def is_visible(self, obj, request):
return obj.status != Article.Status.PUBLISHED
def handle(self, obj, request):
obj.status = Article.Status.PUBLISHED
obj.save(update_fields=["status"])
record_change(request, obj, "updated", "Published.")
return "Published."
@register(Article)
class ArticleStudio(ModelStudio):
edit_sidebar = ["published_at", "author", PublishButton(), ActivityLog()]
labelandicon: the button's text and an icon class, like the studio'sicon.confirm: a question to confirm before the button runs.destructive: shows the button in the style for changes that cannot be undone.allowed_permissions: the studio permissions that allow the button, such as["change"], checked for the object; any one of them is enough. By default the button needs change permission. An empty list,[], lets everyone who can view the object use the button.is_visible(obj, request): hide the button for some objects, such as articles that are already published.
The button checks the permissions and is_visible again when it runs. After
it runs, the edit page shows the object's new values and loads its inlines
again. Unsaved changes on the edit page are kept: the new values then appear
after you save or reload. Record the changes your button makes with
record_change, so they appear in the object's history.
The header
Components in edit_header appear in the edit page's header, next to the
save button. Use it for buttons that act on the whole object:
# blog/admin.py
from content_studio import register
from content_studio.admin import ModelStudio
from content_studio.form import Button
from .models import Article
class PublishButton(Button):
label = "Publish"
allowed_permissions = ["change"]
def handle(self, obj, request):
obj.status = Article.Status.PUBLISHED
obj.save(update_fields=["status"])
return "Published."
@register(Article)
class ArticleStudio(ModelStudio):
edit_header = [PublishButton()]
An inline studio takes
edit_header too: its components appear in the header of the dialog in
which an editor opens one of the inline's objects. That dialog shows the
fields and components of the inline model's own studio, when it has one, and
its links and buttons work there too. The inline studio's permissions decide
who may use them. edit_header only takes
components; system check content_studio.E020 reports anything else.
After creating an object
To tell editors something once they create an object, return it from the
studio's get_created_result(request, obj): a message, or a ButtonResult
whose value appears once in a dialog with a copy button. This suits a value
the admin never shows again, such as a generated key:
# blog/admin.py
from content_studio import register
from content_studio.admin import ModelStudio
from content_studio.form import ButtonResult
from .models import Article
@register(Article)
class ArticleStudio(ModelStudio):
def get_created_result(self, request, obj):
return ButtonResult(
message="The article was created.",
value=obj.get_absolute_url(),
)
Each component gets an ID from its class and its label, so every worker of a
deployment agrees on it. Two components of the same class with the same
label need an explicit component_id (a UUID); manage.py check reports
duplicates.