Skip to main content

🧱 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 for Field(name).
  • FieldLayout(fields=[...], columns=1): a group of fields shown in columns columns.
  • 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.

caution

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.

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 UniqueConstraint on 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 URL get_url(obj, request) returns.
  • ButtonLink: the same as a button, with an optional icon. Set copy = True to 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 the title you 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()]
  • label and icon: the button's text and an icon class, like the studio's icon.
  • 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.