# Django Content Studio > A modern alternative to the classic Django admin, with batteries included. Register a plain Django model and editors get a list page, an edit page with dedicated widgets, a dashboard and a media library, all configured in Python. --- # Introduction Django Content Studio is a modern alternative to the classic Django admin, with batteries included. Register a model and it becomes manageable in the admin: with a list page, search, filters, an edit page with dedicated widgets, inlines, a dashboard and a media library. ## What you get - **Your models, as they are.** Like the classic Django admin, Content Studio manages the models you already have. There is nothing to migrate to and no content model to adopt. - **A complete admin for every registered model.** List pages with search, filters and ordering, and edit pages with validation, inlines and audit stamps. Access follows the same permission hooks as the classic Django admin, including per-object checks. - **Dedicated widgets.** Fields are edited with widgets for dates, rich text, tags, media and structured JSON, and edit pages are laid out with form sets and field layouts. - **A dashboard** with statistics, content lists, recent activity and scheduled tasks, declared in Python. - **A media library** with folders, uploads and image crops, on the media model you choose. - **Collaboration**: notifications about other editors' changes, presence avatars on the objects others have open, and a command palette to jump anywhere. - **Webhooks** that rebuild a static site or notify another service after editors change content. - **Multi-tenancy**: one hook decides which tenants an editor may work in, and every page is scoped to the selected tenant. - **Batteries included.** Optional fields such as `HTMLField`, `TagField` and `FlexField`, and base models such as `ContentModel` and `SingletonModel`, for content-driven projects. ## What it is not Content Studio does not render your website. It manages content, the way the classic Django admin does. The built-in fields and models are optional: everything also works with plain Django models and fields. ## Requirements - Python 3.12 or later - Django 5.2, 6.0 or 6.1 ## See also - [Django Headless](https://djangoheadless.org), if you need an API for your own clients. ## Next steps Start with [installation](https://djangocontentstudio.org/docs/getting-started.md), then read about [registering models](https://djangocontentstudio.org/docs/registering-models.md) and [access control](https://djangocontentstudio.org/docs/permissions.md). To see how Content Studio relates to admin themes and content management systems, read [how it compares](https://djangocontentstudio.org/docs/comparison.md). Source: https://djangocontentstudio.org/docs/intro --- # Getting started This page takes you from installation to your first model in the admin. The examples use a blog project: the project package `mysite` and an app called `blog`. ## Install the package ```bash pip install django-content-studio ``` ## Configure Django 1. Add Content Studio and [Django REST Framework](https://www.django-rest-framework.org/) to `INSTALLED_APPS`. Keep `django.contrib.admin` installed: Content Studio records its activity log with it. The order of the apps does not matter. ```python # mysite/settings.py INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", "content_studio", "blog", ] ``` 2. Mount the admin at a URL prefix. `admin/` is the usual choice; the admin needs no other URL configuration. ```python # mysite/urls.py from django.urls import include, path urlpatterns = [ path("admin/", include("content_studio.urls")), ] ``` ## Initialize Content Studio The `init_content_studio` command sets up the site and the settings: ```bash python manage.py init_content_studio ``` It asks which models to use for the media library (an item and a folder model, none by default), whether to use a tenant model, and which login backend to enable. Choose with the arrow keys; **Esc** asks to cancel. The command then: - creates `studio.py` next to your settings, with the site (a `ContentStudioSite` instance called `admin_site`) and its customization hooks as comments; - adds a `CONTENT_STUDIO` block to your settings with `ADMIN_SITE` pointing at the new site. If the block already exists, the command reports it and leaves your settings as they are. To run the command without prompts, pass every choice as a flag: `--media-item`, `--media-folder` and `--tenant` (each as `app_label.Model`), `--login-backend`, and `--no-input` for the defaults of everything else. Run `manage.py check` afterwards: the [system checks](https://djangocontentstudio.org/docs/deployment.md#system-checks) report any problem with the configuration. ## Register a model Take a plain Django model: ```python # blog/models.py from django.db import models class Article(models.Model): class Status(models.TextChoices): DRAFT = "draft", "Draft" PUBLISHED = "published", "Published" title = models.CharField(max_length=200) body = models.TextField(blank=True) status = models.CharField( max_length=20, choices=Status.choices, default=Status.DRAFT ) def __str__(self): return self.title ``` Register it with a studio in the app's `admin.py`, where Content Studio looks for registrations: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): table_display = ["title", "status"] list_filter = ["status"] search_fields = ["title"] ``` ## Log in Start the development server and open `/admin/`. Log in with the username and password of a staff user, as in the classic Django admin. Articles appear in the menu, with a list page you can search and filter, and an edit page built from the model's fields. ## Next steps - [Registering models](https://djangocontentstudio.org/docs/registering-models.md): every studio option, singletons and inlines. - [List page](https://djangocontentstudio.org/docs/list-page.md): columns, bulk actions, trash, reordering and export. - [Edit page layout](https://djangocontentstudio.org/docs/edit-page.md) and [widgets](https://djangocontentstudio.org/docs/widgets.md): edit page layouts and field editors. - [Access control](https://djangocontentstudio.org/docs/permissions.md): model and object permissions. - [Settings](https://djangocontentstudio.org/docs/settings.md): every `CONTENT_STUDIO` setting. Source: https://djangocontentstudio.org/docs/getting-started --- # Registering models A model appears in the admin once you register it with a studio: a `ModelStudio` subclass that configures its list page and edit page. Put the registrations in your app's `admin.py`, where Content Studio looks for them. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): table_display = ["title", "status", "published_at"] ``` `@register` also takes several models at once, which then share the studio: `@register(Category, LandingPage)`. Registrations go to the site set in the [`ADMIN_SITE`](https://djangocontentstudio.org/docs/settings.md#admin_site) setting. Content Studio keeps its own registry: models registered only with the classic Django admin do not appear in the admin. The user model is the exception. It is registered with a default studio, so editors can manage users from the start; register your own studio for it to change how it looks. ## Studio options | Option | Effect | |--------|--------| | `table_display` | The columns of the table view | | `list_filter` | The fields editors can filter on | | `search_fields` | The fields the search box searches | | `sortable_by` | The columns editors can sort on | | `ordering` | The default sort order of the list page | | `list_per_page` | The number of objects per page (default `20`) | | `list_description` | A description below the model's name on the list page | | `soft_delete` | The field that marks objects as trashed (see [List page](https://djangocontentstudio.org/docs/list-page.md#soft-delete-and-the-trash)) | | `reorder_field` | The field editors reorder objects by (see [List page](https://djangocontentstudio.org/docs/list-page.md#reordering)) | | `allow_duplication` | Whether editors can duplicate objects (see [below](#duplicating-objects)) | | `edit_width` | The width of the edit page: `"default"` or `"wide"` (see [Page width](https://djangocontentstudio.org/docs/edit-page.md#page-width)) | | `presence` | Whether the edit and list pages show who else has an object open (default `True`, see [Presence](https://djangocontentstudio.org/docs/presence.md)) | | `icon` | The model's icon in the main menu, such as `"ph-bold ph-article"` | When `ordering` is not set, objects are sorted by primary key, so pages stay stable. To narrow the objects an editor sees at all, override `get_queryset(request)` (see [Access control](https://djangocontentstudio.org/docs/permissions.md#narrowing-the-objects-an-editor-sees)). Icons come from the bold weight of [Phosphor](https://phosphoricons.com): write `ph-bold ph-` followed by the icon's name. The same goes for every `icon` option in Content Studio. ## Singletons For a model with exactly one object, such as site settings, set `is_singleton` on the studio: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import SiteSettings @register(SiteSettings) class SiteSettingsStudio(ModelStudio): is_singleton = True edit_main = ["site_name", "tagline"] ``` A singleton has no list page: the menu opens its edit page directly. Editors can create the object only while none exists, and cannot delete it. Models built on the built-in `SingletonModel` are treated as singletons without the option. ## Inlines Inlines edit related objects on the parent's edit page, such as the reviews of an article. Declare an inline studio and add it to the parent's studio: ```python # blog/admin.py from content_studio import register from content_studio.admin import InlineModelStudio, ModelStudio from .models import Article, Review class ReviewInline(InlineModelStudio): model = Review table_display = ["text", "stars"] @register(Article) class ArticleStudio(ModelStudio): inlines = [ReviewInline] ``` When the inline model has more than one foreign key to the parent, name the one to use with `fk_name`. The inline's `table_display` sets its columns, and its `edit_width` the width of the dialog its objects open in (see [Page width](https://djangocontentstudio.org/docs/edit-page.md#page-width)). Inline objects only exist within their parent: editors can only add them to parents they may view. The inline studio's permission methods decide what editors can do with the inline's objects, as on any studio. Without add permission, the inline offers no create button. Without change permission, an object opens read-only: its fields are shown but cannot be saved, and it can only be deleted with delete permission. To make an inline read-only for everyone, return `False` from `has_add_permission`, `has_change_permission` and `has_delete_permission`: ```python # blog/admin.py from content_studio.admin import InlineModelStudio from .models import Review class ReviewInline(InlineModelStudio): model = Review table_display = ["text", "stars"] def has_add_permission(self, request, obj=None): return False def has_change_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False ``` ## How related objects appear Wherever an object shows up as a related object (in a picker, in a column or as a selected value) the related model's studio decides how it looks, with `get_relation_display`. By default it shows the object's string representation. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio, RelationDisplay from .models import Category @register(Category) class CategoryStudio(ModelStudio): def get_relation_display(self, obj, request): return RelationDisplay( title=obj.name, description=f"{obj.article_set.count()} articles", icon="ph-bold ph-tag", ) ``` `RelationDisplay` takes a `title` and, optionally: - `description`: a second line of text; - `icon`: a CSS icon class, like the studio's `icon`; - `initials`: a character on a colored circle, the color derived from the initials; - `avatar`: an image URL. When several are set, the admin shows the avatar first, then the initials, then the icon. Models without a studio always show their string representation. ## Duplicating objects To let editors start a new object from a copy of an existing one, set `allow_duplication` on the studio. List the inlines whose objects are copied along in `duplicate_inlines`: ```python # blog/admin.py from content_studio import register from content_studio.admin import InlineModelStudio, ModelStudio from .models import Article, Review class ReviewInline(InlineModelStudio): model = Review table_display = ["text", "stars"] @register(Article) class ArticleStudio(ModelStudio): inlines = [ReviewInline] allow_duplication = True duplicate_inlines = [ReviewInline] ``` The edit page then offers Duplicate in its actions menu. It opens the create form filled in with a copy of the object. Nothing is saved until the editor creates the object, so the copy is validated like any new object. Once it is saved, the objects of the inlines in `duplicate_inlines` are copied to it; the other inlines start empty. The copy takes over: - the values of the fields on the create form, including foreign keys, media and block fields; - the selection of many-to-many fields, which editors can change before saving. It leaves out the primary key, read-only and excluded fields, fields that are not editable, one-to-one fields and files. A many-to-many field with a `through` model is copied only when its through model is an inline listed in `duplicate_inlines`. Unique slugs and URL paths (`URLPathField`) get the first free number suffix, such as `spring-sale-2`. Like the [duplicate warnings](https://djangocontentstudio.org/docs/edit-page.md#unique-fields) on the edit page, the suffix accounts for objects in the trash and, with [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), only looks within the selected tenant. Other unique fields start empty. Inline objects are copied with the same rules. Duplicating needs add permission on the model and view permission on the object. The objects of an inline are copied only when the editor has add permission on it. Duplication is not available for singletons. The new object's history records that it was duplicated, and the [`content_changed` signal](https://djangocontentstudio.org/docs/webhooks.md#react-in-python) reports it like any new object. To change what the copy starts with, override `get_duplicate_initial`. It returns the values by field name: related objects as model instances and many-to-many fields as lists of them. Only fields on the create form are used. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): allow_duplication = True def get_duplicate_initial(self, request, obj): initial = super().get_duplicate_initial(request, obj) initial["title"] = f"Copy of {obj.title}" initial["status"] = Article.Status.DRAFT initial["author"] = request.user return initial ``` System check `content_studio.E021` reports `duplicate_inlines` entries that are not in `inlines`, and `content_studio.W014` options that have no effect. ## Use your own site The default site, `content_studio.admin.admin_site`, is enough for many projects. To add a dashboard, model groups or extensions, subclass `ContentStudioSite` in a module of its own, so your `admin.py` modules can import it: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite class BlogStudioSite(ContentStudioSite): pass admin_site = BlogStudioSite() ``` ```python # mysite/settings.py CONTENT_STUDIO = { "ADMIN_SITE": "mysite.studio.admin_site", } ``` ### Model groups The main menu lists the registered models under one heading, "Content". Set `model_groups` on the site to arrange them in groups of your own, each a heading with its models under it: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite, ModelGroup from blog.models import Article, Category, Redirect, SiteSettings class BlogStudioSite(ContentStudioSite): model_groups = [ ModelGroup("content", icon="ph-bold ph-article", models=[Article, Category]), ModelGroup( "site", label="Site", icon="ph-bold ph-gear", models=[SiteSettings, Redirect], collapsed=True, ), ] admin_site = BlogStudioSite() ``` A group starts open; `collapsed=True` starts it closed, which suits groups editors rarely need. The group that holds the page you are on always opens. Editors open and close groups by clicking the heading. Source: https://djangocontentstudio.org/docs/registering-models --- # List page Every registered model gets a list page, where editors search, filter, sort and act on its objects. You configure it on the model's studio, next to the options on [Registering models](https://djangocontentstudio.org/docs/registering-models.md). The list page shows objects as a **table**, the default, or as a **list** of readable items. Set the views to offer with `list_views`; with more than one, editors switch with a toggle and the admin remembers their choice per model. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): table_display = ["title", "status", "published_at"] list_views = ["table", "list"] list_item_display = { "title": "title", "description": "body", "meta": "get_status_display", "thumbnail": "cover", } ``` ### Table columns `table_display` lists the table's columns: field names, or `TableColumn` objects for a custom header, a format or a computed value. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import TableColumn from content_studio.formats import NumberFormat from .models import Article @register(Article) class ArticleStudio(ModelStudio): table_display = [ "title", TableColumn(field="published_at", header="Published"), TableColumn(field="get_word_count", header="Words", format=NumberFormat), ] def get_word_count(self, obj, request): return len(obj.body.split()) ``` A column's `field` is a model field or a method on the studio. A method takes `(obj, request)` and returns a value its format can show: text for text formats, a number for `NumberFormat`, an ISO 8601 string for the date and time formats. Without a `format`, a method column shows text and a field column uses its field type's format. A column without a `header` uses the field's verbose name; a method column without one has no header. Editors can only sort on columns backed by a model field. `list_display`, the classic Django admin's name for the table columns, also works, so a studio ported from the classic Django admin keeps its columns. System check `content_studio.W010` asks you to rename it to `table_display`; when both are set, `table_display` applies. ### List items `list_item_display` maps the parts of a list item to field or method names: a prominent `title`, a muted `description`, a `meta` label on the right and a `thumbnail` on the left. The `thumbnail` is an image or file field, a media field or a method or property that returns an image URL; in these examples, `cover` is an `ImageField` on `Article`. A media field shows images only, using the site's [small thumbnail](https://djangocontentstudio.org/docs/media-library.md#thumbnails). Once one item on the page has a thumbnail, items without one show a placeholder, so the titles stay aligned. For items computed in code, override `get_list_item_display` instead: ```python # blog/admin.py from content_studio import register from content_studio.admin import ListItemDisplay, ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): list_views = ["table", "list"] def get_list_item_display(self, obj, request): return ListItemDisplay( title=obj.title, description=obj.author.get_full_name(), meta=obj.status, thumbnail=obj.cover.url if obj.cover else "", ) ``` ### Model properties The admin shows a model's `@property` only where the studio names it: as a column in `table_display`, a field in `edit_main` or `edit_sidebar`, or an entry in `readonly_fields`. A property you do not name is never computed and never sent to the admin, so a property that holds a secret or runs a query stays out of every list page and edit page. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): table_display = ["title", "word_count"] ``` Here `word_count` is a property on `Article`, shown as a table column. A property named in `list_item_display` is computed for each item of the list view. ## Bulk actions In the table view, editors select objects (the header checkbox selects the whole page) and run an action from the bar that appears. A selection belongs to one page: paging, filtering or sorting clears it. The built-in delete action is available to editors with delete permission. Add your own actions to the studio with `@bulk_action`; like the actions of the classic Django admin, they receive the request and the selected objects as a queryset: ```python # blog/admin.py from content_studio import register from content_studio.actions import bulk_action from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): @bulk_action( label="Publish", icon="ph-bold ph-paper-plane-tilt", confirm="Publish the selected articles?", allowed_permissions=["change"], ) def publish(self, request, queryset): count = queryset.update(status=Article.Status.PUBLISHED) return f"Published {count} articles." ``` - `label`: the action's name; by default, the method name. - `icon`: an icon class, like the studio's `icon`. - `confirm`: a question to confirm before the action runs. - `allowed_permissions`: the model permissions that allow the action; any one of them is enough. By default the action needs change permission. An empty list, `[]`, lets everyone who can see the list run the action. The permissions are also checked on each selected object (for example `has_change_permission(request, obj)`; with an empty list, `has_view_permission(request, obj)`): if any selected object fails, the action runs on none of them. The return value is the message editors see: a number reports how many objects changed, a string is shown as it is. The built-in actions record every object they change in its history. Your own actions record nothing by themselves. To record a change, call `record_change` for each object, with `"created"`, `"updated"`, `"deleted"`, `"restored"` or `"reordered"` and an optional message. Record a deletion before you delete the object: ```python # blog/admin.py from content_studio import register from content_studio.actions import bulk_action from content_studio.admin import ModelStudio from content_studio.signals import record_change from .models import Article @register(Article) class ArticleStudio(ModelStudio): @bulk_action(label="Publish", allowed_permissions=["change"]) def publish(self, request, queryset): articles = list(queryset) queryset.update(status=Article.Status.PUBLISHED) for article in articles: record_change(request, article, "updated", "Published.") return len(articles) ``` The studio's `actions` attribute chooses which actions are offered: ```python actions = None # the default: delete, export and every @bulk_action actions = [] # no bulk actions actions = ["publish", "delete"] # exactly these, in this order ``` `"export"` names the built-in export, which is offered when the studio [exports its list](#exporting-the-list). System check `content_studio.E007` reports unknown or duplicate names and an action that would replace the built-in delete or export. ## Soft delete and the trash By default, deleting an object removes it from the database. To move objects to a trash instead, set `soft_delete` to a nullable date or date and time field on the model that marks an object as trashed: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): soft_delete = "removed_at" ``` With the option set: - deleting fills in the field instead of removing the object, and the object disappears from the list page; - the list page links to the model's trash, where editors restore objects (with change permission) or delete them for good (with delete permission); - restoring an object is recorded in its history. Any model with such a field works; the built-in `SoftDeletableModel` has one called `removed_at`. System check `content_studio.E008` reports a field that is missing or of the wrong type. ## Reordering To let editors put objects in their own order, add a number field to the model and name it in `reorder_field`. The list page then follows that order, and every row gets a handle to drag it to another position: ```python # blog/models.py from django.db import models class Category(models.Model): name = models.CharField(max_length=50) position = models.PositiveIntegerField(default=0) def __str__(self): return self.name ``` ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Category @register(Category) class CategoryStudio(ModelStudio): table_display = ["name"] reorder_field = "position" ``` With the option set: - the table view and the list view show a drag handle on every row; editors drag rows with the pointer, or focus the handle and move the row with the keyboard (space to pick it up, the arrow keys to move it, space to drop it); - each row's menu moves the object one step up or down, or to the top or the bottom of the list, also when that is on another page. A step up or down passes the neighboring object of the list as currently filtered; - reordering needs change permission, and every move is recorded in the object's history; - reordering is off while editors sort by a column or search, because then the list does not show the manual order. Filtering keeps it on. A move does not call the model's `save()` method or send its signals. When you add the field to existing rows, they can all start at `0`: the first move numbers the list. If you set `ordering` yourself, start it with the reorder field; system check `content_studio.W013` warns when it does not. ### Reordering per group When objects are ordered within a group, such as reviews per article, list the fields that form the group in `reorder_scope`. Objects then move among the objects that share those values: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Review @register(Review) class ReviewStudio(ModelStudio): table_display = ["text", "article"] list_filter = ["article"] reorder_field = "position" reorder_scope = ["article"] ``` Filter the list by the group to see one order at a time. Inlines are always ordered per parent, so an inline needs only `reorder_field`: ```python # blog/admin.py from content_studio.admin import InlineModelStudio from .models import Review class ReviewInline(InlineModelStudio): model = Review table_display = ["text", "stars"] reorder_field = "position" ``` On a model with a tenant field, objects are ordered per tenant (see [Multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md)). ### Your own ordering The built-in reordering works with a number field. If your model keeps its order some other way, such as with django-ordered-model, override `reorder` on the studio. It receives the object and either `after` or `before`: the object to place it next to. Name the field the list is sorted by in `reorder_field`: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Category @register(Category) class CategoryStudio(ModelStudio): reorder_field = "order" def reorder(self, request, obj, *, after=None, before=None): if after is not None: obj.below(after) else: obj.above(before) ``` Return `False` from `reorder` when nothing moved, so no change is recorded. The built-in `ManualOrderModel` has an `order_key` field to use as the reorder field, and its `reorder()` method numbers the order keys from `1` again. Set `order_scope` on the model to the fields that form a group, like `reorder_scope`. System check `content_studio.E017` reports a reorder field that is missing or, without your own `reorder`, not a number field, and `reorder_scope` entries that are not model fields. ## Exporting the list To let editors download the list as a spreadsheet, list the columns to export in `export_fields`. Like `table_display`, it takes field names, `__str__`, methods on the studio and `TableColumn` objects for a custom header: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import TableColumn from .models import Article @register(Article) class ArticleStudio(ModelStudio): table_display = ["title", "status", "published_at"] list_filter = ["status"] search_fields = ["title"] export_fields = [ "title", "status", "author", "published_at", TableColumn(field="word_count", header="Words"), ] def word_count(self, obj, request): return len(obj.body.split()) ``` To export the table's columns instead, set `allow_export = True` and leave `export_fields` out. The list page then offers Export in its toolbar. It downloads every object that matches the current search and filters, on all pages, in the list's current order. In the trash, Export downloads the objects in the trash. The built-in Export bulk action, next to Delete, downloads only the selected objects. Exports are CSV files. To offer Excel files (`.xlsx`) as well, install Content Studio with the `excel` extra, which adds [openpyxl](https://openpyxl.readthedocs.io/): ```bash pip install "django-content-studio[excel]" ``` Each column's header is the field's verbose name, capitalized, or the `TableColumn` header. The cells hold the values as the admin shows them: - the label of a choice, such as `Published`; - the name of a related object, and the names of many-to-many objects separated by commas; - dates and times in ISO format, such as `2026-04-01T18:30:00+02:00`, in the current time zone; - `Yes` or `No` for booleans, and rich text without its markup. In Excel files, numbers and dates are real numbers and dates, shown in ISO format. Exporting needs view permission on the model. An export holds the objects of the selected tenant only, with [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), and leaves out objects in the trash (except when exporting the trash) and the fields in the studio's `exclude`. A value that a spreadsheet application would read as a formula, because it starts with `=`, `+`, `-` or `@`, gets a leading apostrophe so it stays text. Export is not available for singletons. To offer export in the toolbar only, leave `"export"` out of an explicit [`actions`](#bulk-actions) list. System check `content_studio.E022` reports `export_fields` entries that do not resolve, and `content_studio.W015` options that have no effect. Source: https://djangocontentstudio.org/docs/list-page --- # Edit page layout 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`. ```python # 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, when=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, when=None)`**: a titled block of fields, the main way to group the edit page. - **`FormSetGroup(label, formsets=[...], when=None)`**: a group of form sets. `when` shows the item only under a condition; see [Conditional fields](#conditional-fields). 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. ## 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)`, or an instance with options: `Field("body", widget=widgets.TextAreaWidget(rows=12))`. See [Widgets](https://djangocontentstudio.org/docs/widgets.md) for the widgets you can use and their options. ## Page width The main column of the edit page is at most 800px wide, so fields stay easy to read whether or not the studio has a sidebar. The sidebar sits next to it. The dialogs that inline objects open in fit their editor in the same way. Some content needs more room, such as an inline table with many columns or rows of four fields. Set `edit_width = "wide"` on the studio to let the main column fill the page. On an inline studio, it widens the dialog the inline's objects open in: ```python # blog/admin.py from content_studio import register from content_studio.admin import InlineModelStudio, ModelStudio from .models import Article, Review class ReviewInline(InlineModelStudio): model = Review table_display = ["text", "stars"] edit_width = "wide" @register(Article) class ArticleStudio(ModelStudio): inlines = [ReviewInline] edit_width = "wide" ``` `edit_width` is `"default"` or `"wide"`. System check `content_studio.E024` reports any other value. ## 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: ```python # 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. ## Conditional fields `when` shows a field, a component, a form set or a tab only while the form's values meet a condition. Write the condition as a Django [`Q` object](https://docs.djangoproject.com/en/stable/topics/db/queries/#complex-lookups-with-q-objects) over the model's fields. The edit page applies it as the editor types: ```python # blog/admin.py from django.db.models import Q from django.db.models.functions import Now from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import Callout, Field, FormSet from .models import Article @register(Article) class ArticleStudio(ModelStudio): edit_main = [ FormSet( title="Content", fields=["title", "body", "status"], ), FormSet( title="Publishing", fields=[ "published_at", Callout( "Scheduled: the article goes live at its publication date.", when=Q(published_at__gt=Now()), ), ], when=Q(status=Article.Status.PUBLISHED), ), ] edit_sidebar = [ "featured", Field("views", when=Q(featured=True) | Q(views__gt=0)), ] ``` The Publishing form set shows once the status is Published, and its callout while the publication date lies ahead. `when` takes the same argument on every component, such as `Text`, `Callout`, `Button` or `SEOPreview`, and on the components of `edit_header`. On a component you subclass, you can set `when` as a class attribute. Combine conditions with `&`, `|` and `~`, like `Q` objects in a query. They read the values of the model's own fields, with these lookups: `exact`, `iexact`, `in`, `isnull`, `gt`, `gte`, `lt`, `lte`, `range`, `contains`, `icontains`, `startswith`, `istartswith`, `endswith` and `iendswith`. A value is one of: - **a literal**: text, a number, a boolean, `None`, a date, a date and time, a time, a model instance or a choice such as `Article.Status.PUBLISHED`; - **another field**: `F("published_at")`; - **the current time**: `Now()`, optionally with an offset, such as `Now() + timedelta(days=7)`. A date field compares with the current date in the site's [`TIME_ZONE`](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-TIME_ZONE). A foreign key compares by the related object's primary key: `Q(author=3)`. A many-to-many field matches when any of its objects does, so `Q(categories=2)` holds for articles in category 2, like in a query. An empty text field equals `""`, also in a new object's form. Related fields (`author__username`) and transforms (`published_at__year`) are not supported: the condition reads the form, not the database. System check `content_studio.E040` reports a condition on a field the model does not have, or on an excluded or write-only field. ### Saving hidden fields A hidden field is not saved. Its stored value stays as it is and comes back when the field shows again, and a value an editor typed before hiding it is dropped. Content Studio decides this again when the object is saved, from the values the editor sends, so an editor cannot change a hidden field by other means. The same holds for the fields in a hidden form set or tab. A field placed twice is saved when one of its places shows. A hidden field is not required. So a required model field that only shows under a condition makes creating an object fail while it is hidden; system check `content_studio.W024` reports it. Give the field a default, make it nullable or blank, or show it always. > **Note** > > Conditions decide what the edit page shows, not who may change what: an > editor can show a field by changing the value it depends on. Use > [read-only fields](#read-only-fields) and > [permissions](https://djangocontentstudio.org/docs/permissions.md) to restrict editors. ### Server-side conditions For a rule a `Q` object cannot express, subclass `Condition` and decide in Python. `fields` lists the form values `check` reads: ```python # blog/admin.py from django.db.models import Q from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import Callout, Condition, FormSet from .models import Article class MeetsHouseStyle(Condition): fields = ["body"] def check(self, values, request): body = values["body"] or "" sentences = [s for s in body.split(".") if s.strip()] return all(len(sentence.split()) <= 25 for sentence in sentences) @register(Article) class ArticleStudio(ModelStudio): edit_main = [ FormSet( title="Content", fields=[ "title", "body", Callout( "Keep sentences under 25 words.", title="House style", variant="warning", when=~Q(body="") & ~MeetsHouseStyle(), ), ], ), ] ``` While the editor types, the admin sends the values of `fields` to the server once the form rests, and shows or hides what the condition guards when the answer arrives. Until then it stays hidden. `check` receives the values as the form holds them: text, numbers and booleans as they are, related objects as their primary key (a list for many-to-many fields), dates and times as ISO 8601 strings, and `None` for empty fields. It runs for every pause in typing, and again on save for conditions that guard fields, so keep it fast and without side effects. ## 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: ```python # 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 ``` ```python # 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](https://djangocontentstudio.org/docs/list-page.md#soft-delete-and-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. ```python # 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: text, links, buttons that run your code, and the object's activity log. ```python # 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. - **`Title`**, **`Subtitle`**, **`Text`** and **`Callout`**: text for the editor to read, such as headings and instructions; see below. - **`SEOPreview`**: how the object would show in a search result, as the editor types; see [SEO preview](#seo-preview). Only these components are supported; other component types are not. Links and buttons appear once the object is saved. ### Text The text components show information on the edit page; they are not inputs. Place them in `edit_main`, `edit_sidebar` or the `fields` of a `FormSet`, between the fields they describe: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import Callout, FormSet, Subtitle, Text, Title from .models import Article @register(Article) class ArticleStudio(ModelStudio): edit_main = [ FormSet( fields=[ Title("Article"), Text("Write for the web: short paragraphs, **one idea** each."), "title", "body", Subtitle("Publishing"), "status", ] ) ] edit_sidebar = [ Callout( "Published articles change on the site as soon as you save.", title="Heads up", variant="warning", ), "published_at", "author", ] ``` - **`Title`**: a heading, in plain text. - **`Subtitle`**: a smaller heading, in plain text. - **`Text`**: a paragraph in Markdown: bold, italic, links, lists and paragraphs. HTML in the text is not rendered, and links open in a new tab. - **`Callout`**: Markdown text in a highlighted box, with an optional `title`. The `variant` sets its color and icon: `"info"` (the default), `"success"`, `"warning"` or `"danger"`. Pass an `icon`, such as `"ph-bold ph-lightbulb"`, to use another [Phosphor icon](https://phosphoricons.com). To show text that depends on the object, subclass the component and override `get_text(obj, request)`. It runs when the editor opens a saved object, so text computed this way does not appear on the create form: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import Callout from .models import Article class ReadingTime(Callout): title = "Reading time" def get_text(self, obj, request): minutes = max(1, len(obj.body.split()) // 200) return f"About **{minutes} min** to read." @register(Article) class ArticleStudio(ModelStudio): edit_sidebar = [ReadingTime(), "published_at", "author"] ``` The text is computed again after the editor saves. Text components are not allowed in a `FieldLayout` or in `edit_header`; system check `content_studio.E020` reports one in the header. Two text components with the same text and type get the same ID. To show the same text twice, pass `component_id` with a UUID of your own to one of them. ### SEO preview `SEOPreview` shows a search-result snippet of the object being edited: the value of `title_field` as the heading, the value of `description_field` under it, and the page's URL above them. It follows the form as the editor types, also on the create form. A title over about 60 characters and a description over about 160 are cut off the way search engines cut them, with a note under the snippet. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import FormSet, SEOPreview from .models import Article @register(Article) class ArticleStudio(ModelStudio): prepopulated_fields = {"slug": ["title"]} edit_main = [ FormSet( title="Search", fields=[ "slug", "meta_description", SEOPreview( title_field="title", description_field="meta_description", base_url="https://example.com/blog/", path_field="slug", site_name="Example", ), ], ), ] ``` - **`title_field`**: the field with the page's title. Required. - **`description_field`**: the field with the description; HTML in it shows as text. Without it, the snippet has no description. - **`base_url`** and **`path_field`**: the page's URL is `base_url` joined with the value of `path_field`, a slug or a path. Without them, the snippet has no URL. - **`site_name`**: the site's name, shown above the URL. Like the text components, it goes in `edit_main`, `edit_sidebar` or a `FormSet`, not in `edit_header`. The fields can also be set as class attributes of a subclass. System check `content_studio.E036` reports a field the model does not have. ### 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. ```python # 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: ```python # 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](https://djangocontentstudio.org/docs/registering-models.md#inlines) 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, and no text 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: ```python # 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(), ) ``` Two components of the same class with the same label need an explicit `component_id` (a UUID) to tell them apart; `manage.py check` reports duplicates. ### Object history The `ActivityLog` component shows an object's history on its edit page: every addition, change and deletion, newest first. Add it to the sidebar or to any form set: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import ActivityLog, FormSet from .models import Article @register(Article) class ArticleStudio(ModelStudio): edit_sidebar = [ FormSet(fields=["published_at", "author"]), FormSet(fields=[ActivityLog(title="History")]), ] ``` The title is "Activity" unless you pass one. The history appears on the edit page of saved objects only, for editors who may view the object. Each `FormSet` in the sidebar is its own card, so the history above gets a card of its own. Source: https://djangocontentstudio.org/docs/edit-page --- # Widgets Every field on the edit page is edited with a widget. Content Studio picks the widget from the field's type, so most models need no configuration. ## Default widgets | Field type | Widget | | ----------------------------------------- | -------------------------------------------- | | `CharField` and number fields | `InputWidget` | | `EmailField` | `EmailWidget` | | `URLField` | `URLWidget` | | `SlugField` | `SlugWidget` | | `TextField` | `TextAreaWidget` | | `BooleanField` | `CheckboxWidget` | | `ForeignKey`, `OneToOneField` | `ForeignKeyWidget` | | `ManyToManyField` | `ManyToManyWidget` | | `DateField`, `DateTimeField`, `TimeField` | `DateWidget`, `DateTimeWidget`, `TimeWidget` | | `DurationField` | `DurationWidget` | | `FileField`, `ImageField` | `FileWidget` | | `JSONField` | `JSONWidget` | | `HTMLField` (built-in) | `RichTextWidget` | | `TagField` (built-in) | `TagWidget` | | `FlexField` (built-in) | `JSONSchemaWidget` | | `BlockField` (built-in) | `BlockWidget` | | `MultipleChoiceField` (built-in) | `MultiSelectWidget` | | `URLPathField` (built-in) | `URLPathWidget` | | `ColorField` (built-in) | `ColorPickerWidget` | | `MediaField`, `ManyMediaField` (built-in) | `MediaWidget`, `ManyMediaWidget` | | `AutoSlugField` (django-extensions) | `SlugWidget` | Fields with choices get a select. Fields of other types get a plain input. These widgets are not used by default but are there to choose: - **`SwitchWidget`**: a toggle switch, for a `BooleanField`. - **`PhoneWidget`**: a phone number, for a `CharField`. Phones show the number pad. - **`PasswordWidget`**: a masked value with a button that shows it, for a secret such as an API key in a `CharField`. See [Secrets](#secrets). - **`SliderWidget`**: a number picked on a slider, for a number field. It needs its range: see [Widget options](#widget-options). - **`ToggleGroupWidget`**: a row of buttons for a field with choices, of which exactly one is active. - **`RadioButtonWidget`**: the choices of a field as radio buttons, one per line. An optional field can be cleared. - **`CoverImageWidget`**: one image shown large, for a `MediaField` that holds a cover or header image. See [Cover images](#cover-images). - **`MarkdownWidget`**: Markdown typed as text, for a `TextField` that stores Markdown. A toolbar adds headings, bold, italic, links and lists (also with Ctrl or ⌘ + B, I and K), and the Preview tab shows the text rendered. Raw HTML is not rendered. - **`CodeWidget`**: a code editor with line numbers, for embed codes and snippets in a `TextField`. Pass a `language` for syntax highlighting. Tab indents; press Escape, then Tab, to leave the editor. - **`IconPickerWidget`**: a [Phosphor](https://phosphoricons.com) icon picked from a searchable grid, for a `CharField`. It saves the icon's class, such as `"ph-bold ph-star"`, which you use as `` on your site with the Phosphor Bold font. - **`KeyValueWidget`**: a `JSONField` dict of strings, edited as rows of a key and a value. A key that is empty or used twice keeps the form from saving. JSON objects have no order (PostgreSQL sorts their keys), so the rows cannot be reordered. - **`ListWidget`**: a `JSONField` list of strings, edited as rows that can be added, removed and dragged into order. Enter adds a row. Unlike `TagWidget`, the order is kept and the same value can appear twice. - **`RatingWidget`**: stars for a small integer, such as a review score. Clicking the current star again clears an optional field. - **`MapWidget`**: a location picked on a map, for a `JSONField` or a pair of number fields. See [Maps](#maps). - **`EmbedWidget`**: the URL of a video, song or post on YouTube, Vimeo, Spotify and other sites, with a preview. See [Embeds](#embeds). The `JSONWidget`, the default for a `JSONField`, is a JSON editor. JSON that does not parse is underlined and keeps the form from saving. The code editor of `CodeWidget` and `JSONWidget` is loaded only on pages that have one of them. ## Use another widget for one field Pass a widget class to a `Field` in `edit_main` or `edit_sidebar`: ```python # blog/admin.py from content_studio import register, widgets 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( fields=[ "title", Field("featured", widget=widgets.SwitchWidget), ], ), ] ``` The widget only changes how the field is edited. Read-only fields keep showing their value through its format. ## Widget options Some widgets take options. Pass an instance with the options instead of the class; a class is the same widget without options: ```python # blog/admin.py import datetime from content_studio import register, widgets 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( fields=[ Field( "title", widget=widgets.InputWidget(placeholder="A short, clear title"), ), Field("body", widget=widgets.TextAreaWidget(rows=12)), Field( "published_at", widget=widgets.DateTimeWidget(min_date=datetime.date.today), ), ], ), ] ``` Options are keyword arguments. An unknown option or an invalid value raises an error when the studio is defined, not when an editor opens the page. - **`InputWidget(placeholder=None, prefix=None, suffix=None, min_value=None, max_value=None, step=None, show_count=None, recommended_length=None)`**: `prefix` and `suffix` are shown inside the box, around the value, for a currency or a unit. `min_value`, `max_value` or `step` make it a number input. For `show_count` and `recommended_length`, see [Character counter](#character-counter). - **`TextAreaWidget(rows=None, placeholder=None, show_count=None, recommended_length=None)`**: the textarea grows with its text; `rows` sets the height it starts at. - **`EmailWidget(placeholder=None)`**, **`URLWidget(placeholder=None)`** and **`PhoneWidget(placeholder=None)`**: an email address gets a button that writes to it, and a web address one that opens it in a new tab. - **`PasswordWidget(write_only=False, placeholder=None)`**: see [Secrets](#secrets). - **`SliderWidget(min_value, max_value, step=1, suffix=None)`**: a slider from `min_value` to `max_value`, with the value next to it. `suffix` is shown after the value, for a unit. `min_value` and `max_value` are required. - **`DurationWidget(show_days=False, show_seconds=True)`**: boxes for hours, minutes and seconds. `show_days=True` adds a days box; without it, a day counts as 24 hours. `show_seconds=False` leaves out the seconds. - **`RichTextWidget(features=None, heading_levels=None)`**: `features` limits the editor to the listed formatting. Choose from `"heading"`, `"blockquote"`, `"code_block"`, `"bold"`, `"italic"`, `"underline"`, `"strike"`, `"ordered_list"`, `"bullet_list"` and `"link"`. Formatting that is not listed has no button, and is removed from text that editors paste. `heading_levels` limits the heading levels, such as `[2, 3]`. - **`DateWidget(min_date=None, max_date=None)`** and **`DateTimeWidget(min_date=None, max_date=None)`**: limit the days the picker offers. Pass a date, or a callable that returns one, such as `datetime.date.today`, for a limit that moves with time. - **`CoverImageWidget(aspect_ratio=None, min_width=None, min_height=None)`**: see [Cover images](#cover-images). - **`ColorPickerWidget(show_input=True, swatches=None)`**: see [Colors](#colors). - **`MarkdownWidget(rows=None, placeholder=None, show_count=None, recommended_length=None)`**: the options of `TextAreaWidget`. `rows` sets the height (8 lines by default). - **`CodeWidget(language=None)`**: the language to highlight: `"css"`, `"html"`, `"javascript"` or `"json"`. Without it, the code is plain text. - **`RatingWidget(max_value=5)`**: the number of stars, from 2 to 10. - **`MapWidget(lng_field=None, center=None, zoom=None)`**: see [Maps](#maps). - **`EmbedWidget(placeholder=None)`**: see [Embeds](#embeds). > **Note** > > Options guide editors; they do not validate. To enforce a range or a > format, add [validators](https://docs.djangoproject.com/en/stable/ref/validators/) > to the model field, such as `MinValueValidator`, and an `HTMLField`'s > [sanitizer](https://djangocontentstudio.org/docs/settings.md#html_sanitizer) for the HTML it keeps. ## Character counter A text field with a `max_length` shows a counter under its box once the text reaches 80% of the limit, such as "52 / 60". It turns red past the limit. `show_count=True` always shows the counter, and `show_count=False` never does. `recommended_length` is a soft limit, such as the length a search engine shows of a title. The counter counts against it, starts at 80% of it, and turns orange past it. The field still saves: ```python # blog/admin.py Field("title", widget=widgets.InputWidget(recommended_length=60)), ``` ## Secrets `PasswordWidget` masks its value. Editors can show it with the eye button. With `write_only=True`, the saved value never leaves the server: the API says whether a value is saved, and the box stays empty. Editors type a new value to replace it, and an optional field has a **Clear** button that removes it on save. CSV and Excel exports leave the field out. ```python # blog/admin.py Field("api_key", widget=widgets.PasswordWidget(write_only=True)), ``` `write_only` applies to the field wherever the studio's form has it. The value is stored as you store it: to encrypt it in the database, use an encrypted model field. ## Files `FileWidget` edits a `FileField` or an `ImageField`. Editors pick a file or drop it on the field; an `ImageField` only takes images and shows a preview. The file is uploaded right away and attached when the editor saves. Until then, the object keeps its saved file, so **Cancel** leaves it unchanged. Uploads go through the same checks as the media library: the field's own validators (an `ImageField` checks that the file is an image), a maximum size ([`UPLOAD_MAX_SIZE`](https://djangocontentstudio.org/docs/settings.md#upload_max_size)), and no markup such as HTML and SVG. A picked file waits in [`UPLOAD_FOLDER`](https://djangocontentstudio.org/docs/settings.md#upload_folder) in the default storage. It is moved into the field's `upload_to` on save. Files that were picked but never saved stay in the upload folder. Remove them once a day, for example with cron: ```bash python manage.py content_studio_clear_uploads ``` It removes uploads older than a day; an editor's upload is valid for that long. ## Maps `MapWidget` picks a location on a map. Editors click the map to place the pin, drag it, search for an address, or type the latitude and longitude in the boxes under the map. The map is loaded only on pages that have a map field. The location is stored in one of two ways: - In a `JSONField`, as `{"lat": 52.37, "lng": 4.89}`. Give the field `null=True` so an optional location can be cleared. - In two number fields (`FloatField` or `DecimalField`): put the widget on the latitude field and name the longitude field with `lng_field`. The widget fills both. Leave the longitude field out of the form, or it shows as a plain number box too. ```python # blog/models.py class Venue(models.Model): location = models.JSONField(null=True, blank=True) class Shop(models.Model): latitude = models.FloatField(null=True, blank=True) longitude = models.FloatField(null=True, blank=True) # blog/admin.py Field("location", widget=widgets.MapWidget(center=(52.37, 4.89), zoom=10)), Field("latitude", widget=widgets.MapWidget(lng_field="longitude")), ``` - **`lng_field`**: the number field holding the longitude, when the widget is on a latitude field. - **`center`** and **`zoom`**: where the map starts while the field is empty, as a `(latitude, longitude)` pair and a zoom from 0 (the world) to 19 (a street). By default, the map shows the world. The map shows [OpenStreetMap](https://www.openstreetmap.org) tiles and searches addresses with its Nominatim service, both from the editor's browser; their usage policies allow this for an admin's occasional use. To use your own tile server or geocoder, or to leave the search out, set [`MAP_TILE_URL`, `MAP_TILE_ATTRIBUTION`](https://djangocontentstudio.org/docs/settings.md#map_tiles) and [`MAP_GEOCODER_URL`](https://djangocontentstudio.org/docs/settings.md#map_geocoder_url). System check `content_studio.E035` reports a `MapWidget` on a field it cannot edit. ## Embeds `EmbedWidget` takes the URL of a video, song or post on a site that offers [oEmbed](https://oembed.com), such as YouTube, Vimeo, Spotify, SoundCloud, TikTok, Flickr, Bluesky and X. The admin shows a preview with the title and thumbnail, which the server fetches from the site; the value stays the URL as typed, for your templates to embed. A URL on another site is saved too, without a preview. ```python # blog/admin.py Field("video_url", widget=widgets.EmbedWidget), ``` The sites are listed in [`OEMBED_PROVIDERS`](https://djangocontentstudio.org/docs/settings.md#oembed_providers), which you can replace with your own, such as a self-hosted PeerTube. Previews are cached for a day. ## Cover images `CoverImageWidget` shows the image of a `MediaField` across the width of the form, in a 16:9 frame by default, the way a cover or header image appears on a site. Use it for one field in `edit_main`: ```python # blog/models.py from django.db import models from content_studio.media_library.fields import MediaField class Article(models.Model): title = models.CharField(max_length=200) cover = MediaField("blog.MediaItem") ``` ```python # blog/admin.py from content_studio import register, widgets 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( fields=[ Field("cover", widget=widgets.CoverImageWidget), "title", ], ), ] ``` Editors drop an image on the frame, upload one or choose one from the [media library](https://djangocontentstudio.org/docs/media-library.md). Uploads are added to the top folder of the library, which needs the media model's add permission. Only images are accepted. Once an image is chosen, the frame crops it to its shape around its focal point. Below the image, editors edit its alt text, and from the frame they set its focal point: - **Alt text** is saved on the media item when the editor leaves the input. It shows when the media item model has an `alt_text` field. - **Focal point** opens a dialog with previews of the image cropped to 21:9, 4:3 and 1:1. It shows when the media item model has a [properties](https://djangocontentstudio.org/docs/media-library.md#properties) field. Both belong to the media item, not to the article: a change applies wherever the image is used, and needs the media model's change permission. To match how your site shows the image, pass options: - **`aspect_ratio`** sets the frame's shape, such as `"21:9"` for a banner or `"1:1"` for a square. It is `"16:9"` when unset. - **`min_width`** and **`min_height`** are the smallest size in pixels your templates need. Editors see a warning when the image is smaller, so a cover does not end up blurry. The image is still accepted. With [media metadata](https://djangocontentstudio.org/docs/media-library.md#metadata), the stored size is used; without it, the editor's browser loads the full image to measure it. ```python # blog/admin.py from content_studio import register, widgets 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( fields=[ Field( "cover", widget=widgets.CoverImageWidget( aspect_ratio="21:9", min_width=2100, min_height=900 ), ), "title", ], ), ] ``` System check `content_studio.W019` reports a `CoverImageWidget` on a field that is not a `MediaField` to the media library model. ## Colors `ColorField` stores a CSS color, edited with a color picker. It is a `CharField` that saves the color in one notation, chosen with `color_format`: `"hex"` (the default), `"rgb"` or `"hsl"`. Set `alpha=True` to allow transparent colors. ```python # blog/models.py from django.db import models from content_studio.fields import ColorField class Category(models.Model): name = models.CharField(max_length=100) color = ColorField(blank=True) overlay = ColorField(color_format="rgb", alpha=True, blank=True) ``` Editors can type or paste a color in any of the three notations, such as `#336699`, `rgb(51 102 153)` or `hsl(210, 50%, 40%)`. Anything else is rejected. On save, the color is converted to the field's notation, so your templates always get the same form: | `color_format` | Saved | With `alpha=True` | | -------------- | -------------------- | -------------------------- | | `"hex"` | `#336699` | `#33669980` | | `"rgb"` | `rgb(51, 102, 153)` | `rgba(51, 102, 153, 0.5)` | | `"hsl"` | `hsl(210, 50%, 40%)` | `hsla(210, 50%, 40%, 0.5)` | With `alpha=True`, every color is saved with its alpha, also an opaque one. Without it, transparent colors are rejected. `ColorPickerWidget` shows a swatch that opens the picker, next to a text input for typing or pasting a color. Pass options to change it: - **`show_input`**: `False` leaves out the text input. Editors pick a color with the picker only. - **`swatches`**: a list of colors offered below the picker, such as your brand palette. They can be in any notation and are converted to the field's. ```python # blog/admin.py from content_studio import register, widgets from content_studio.admin import ModelStudio from content_studio.form import Field, FormSet from .models import Category @register(Category) class CategoryStudio(ModelStudio): table_display = ["name", "color"] edit_main = [ FormSet( fields=[ "name", Field( "color", widget=widgets.ColorPickerWidget( swatches=["#0f172a", "#2563eb", "#16a34a", "#dc2626"], ), ), Field( "overlay", widget=widgets.ColorPickerWidget(show_input=False), ), ], ), ] ``` On the list page, a `ColorField` shows a swatch next to its value (`ColorFormat`). To keep a plain `CharField`, add a `ColorValidator` and choose the widget. `ColorValidator(color_format=None, alpha=False)` accepts colors in the given notation only, or in all three with `None`. A plain field does not convert colors on save, so give the validator a `color_format` to keep them in one notation. The picker writes the validator's notation, or hex without one. ```python # blog/models.py from django.db import models from content_studio.validators import ColorValidator class Tag(models.Model): name = models.CharField(max_length=50) color = models.CharField( max_length=7, blank=True, validators=[ColorValidator(color_format="hex")] ) ``` ```python # blog/admin.py from content_studio import register, widgets from content_studio.admin import ModelStudio from content_studio.form import Field, FormSet from .models import Tag @register(Tag) class TagStudio(ModelStudio): edit_main = [ FormSet(fields=["name", Field("color", widget=widgets.ColorPickerWidget)]), ] ``` ## Change the default for a field type To change the widget for every field of a type, extend the site's `default_widget_mapping`: ```python # mysite/studio.py from django.db import models from content_studio import widgets from content_studio.admin import ContentStudioSite class BlogStudioSite(ContentStudioSite): default_widget_mapping = { **ContentStudioSite.default_widget_mapping, models.EmailField: widgets.InputWidget, } admin_site = BlogStudioSite() ``` ## Formats Values on the list page and in read-only fields are shown through formats. Content Studio picks the format from the field's type: | Field type | Format | Shows | | ----------------------------------------- | -------------------------------- | ----------------------------------------------------------------------- | | `CharField`, `SlugField` | `TextFormat` | The text | | `TextField` | `TextFormat(lines=2)` | Two lines on the list page, all of it on hover and in a read-only field | | Number fields | `NumberFormat` | Grouped digits in the editor's language (45,940) | | `BooleanField` | `BooleanFormat` | A check or a dash | | `DateField` | `DateFormat` | The date in the editor's language | | `DateTimeField` | `DateTimeFormat` | The time relative to now, exact on hover | | `TimeField` | `TimeFormat` | Hours and minutes | | `DurationField` | `DurationFormat` | The duration in words, such as 1h 20m | | `UUIDField` | `CodeFormat` | The value in a monospace font, with a copy button | | `EmailField` | `EmailFormat` | A `mailto:` link | | `URLField` | `LinkFormat` | A link that opens in a new tab | | `FileField` | `FileFormat` | The file's name, linked to the file | | `ImageField` | `ImageFormat` | A small thumbnail, linked to the image | | `ForeignKey`, `OneToOneField` | `ForeignKeyFormat` | The related object | | `ManyToManyField` | `ManyToManyFormat` | The related objects as chips | | `JSONField`, `FlexField` (built-in) | `JSONFormat` | The JSON | | `HTMLField` (built-in) | `HtmlFormat` | An excerpt on the list page, the formatted text in a read-only field | | `TagField` (built-in) | `TagFormat` | The tags as pills | | `ColorField` (built-in) | `ColorFormat` | A swatch with the value | | `BlockField` (built-in) | `BlockFormat` | The number of blocks | | `MediaField`, `ManyMediaField` (built-in) | `MediaFormat`, `ManyMediaFormat` | The thumbnails | Fields with choices show the choice's label. Lists of tags, related objects and media show the first three, then "+2" for the rest. These formats are not used by default but are there to choose: - **`CurrencyFormat`**: an amount in a currency, such as €1,250.00. - **`PercentFormat`**: a percentage, such as 91%. - **`BadgeFormat`**: a value as a colored badge, for a status. - **`ProgressFormat`**: a number as a small meter, with the number next to it, for a score or a completion percentage. - **`CodeFormat`**: for any identifier, such as a SKU or a key. - **`FileSizeFormat`**: a number of bytes, such as 1.2 MB. ### Use another format for a column Pass a format to a `TableColumn`, as a class or as an instance with options: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.form import TableColumn from content_studio.formats import BadgeFormat, DateTimeFormat, NumberFormat from .models import Article @register(Article) class ArticleStudio(ModelStudio): table_display = [ "title", TableColumn( field="status", format=BadgeFormat(colors={"draft": "gray", "published": "green"}), ), TableColumn(field="published_at", format=DateTimeFormat(relative=False)), TableColumn( field="get_word_count", header="Words", format=NumberFormat(compact=True) ), ] def get_word_count(self, obj, request): return len(obj.body.split()) ``` ### Format options Options are keyword arguments, checked when the format is made: an unknown style or color raises an error when Django starts. ```python # blog/admin.py TableColumn(field="body", format=TextFormat(max_length=80)), ``` - **`NumberFormat`**: `decimals` fixes the digits after the decimal point (up to two are shown without it). `compact=True` shortens large numbers (45.9K) and shows the exact value on hover. `prefix` and `suffix` are shown next to the number in muted text, such as `suffix="km"`. - **`CurrencyFormat`**: the currency as an ISO 4217 code, such as `"EUR"` or `"USD"`, and `decimals` and `compact` as for `NumberFormat`. Without `decimals`, the currency's own minor units are shown (cents for euros). - **`PercentFormat`**: the value is the percentage, so 91 shows as 91%. With `fraction=True` the value is a fraction of one, so 0.91 shows as 91%. `decimals` as for `NumberFormat`; one decimal is shown without it. - **`BadgeFormat`**: `colors` maps values to `"gray"`, `"blue"`, `"green"`, `"yellow"`, `"red"` or `"purple"`; values not in it get `default_color` (`"gray"`). The colors are tuned for the light and dark theme. A field with choices shows the choice's label; a `MultipleChoiceField` shows a badge per value. - **`DateFormat`**: `style` is `"short"` (10/9/26), `"medium"` (Oct 9, 2026, the default) or `"long"` (October 9, 2026), in the editor's language. - **`DateTimeFormat`**: `relative=False` shows the date and time instead of "3 hours ago"; `style` sets the date's style, as for `DateFormat`. - **`TextFormat`**: `max_length` cuts the text at that many characters and `lines` clamps it to that many lines. The full text shows on hover. A read-only field on the edit page always shows all of it. - **`DurationFormat`**: `style` is `"narrow"` (1h 20m, the default), `"short"` (1 hr, 20 min) or `"long"` (1 hour, 20 minutes), in the editor's language. - **`ProgressFormat`**: `max_value` is the value of a full meter (`100`). Values below 0 or above it show an empty or a full meter. - **`BooleanFormat`**: `labels=("In stock", "Sold out")` shows the first word for true and the second, muted, for false. Add `colors=("green", "red")` to show them as badges, in the colors of `BadgeFormat`. ### Change the default format for a field type The site's `default_format_mapping` maps field types to formats, and you change it the same way as `default_widget_mapping`. To show every `DecimalField` as euros: ```python # mysite/studio.py from django.db import models from content_studio import formats from content_studio.admin import ContentStudioSite class BlogStudioSite(ContentStudioSite): default_format_mapping = { **ContentStudioSite.default_format_mapping, models.DecimalField: formats.CurrencyFormat("EUR"), } admin_site = BlogStudioSite() ``` ## Custom widgets and formats Only the built-in widgets and formats are supported. The admin has no way to show a widget or format class of your own. Source: https://djangocontentstudio.org/docs/widgets --- # Block fields A block field holds an ordered list of content blocks, such as text and images. Editors add, reorder and remove blocks freely, so they can build a page body from the blocks you define. Use it for content an editor composes; give anything with a fixed place on the page, such as a page's title or header image, a regular field. ## Define blocks A block is a `Block` subclass that declares its fields as Django model fields. Add the blocks to a `BlockField` on your model: ```python # blog/models.py from django.db import models from content_studio.blocks import Block, BlockField from content_studio.fields import HTMLField from content_studio.media_library.fields import MediaField class TextBlock(Block): body = HTMLField() class Meta: description = "Rich text with headings and lists" icon = "ph-bold ph-text-t" class ImageBlock(Block): image = MediaField("blog.MediaItem", blank=False) caption = models.CharField(max_length=200, blank=True) class Meta: description = "An image from the media library" icon = "ph-bold ph-image" class LandingPage(models.Model): title = models.CharField(max_length=200) sections = BlockField(blocks=[TextBlock, ImageBlock], max_num=20) def __str__(self): return self.title ``` Register the model as usual. The edit page shows the block field with its own widget; the studio needs no configuration for it: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import LandingPage @register(LandingPage) class LandingPageStudio(ModelStudio): table_display = ["title"] ``` On the edit page, editors add a block from a searchable list, also between two blocks, and edit its fields with the same widgets as the fields of a model. They reorder blocks by dragging or with the keyboard, and collapse, duplicate and remove them. Tables show the number of blocks. Blocks are not models, so adding or changing a block needs no migration. ## Block options Set these on the block's `Meta`: | Option | Default | Description | |--------|---------|-------------| | `name` | The class name without `Block`, in snake case: `ImageBlock` is `"image"` | The block's type in the stored data. Set it to rename the class without breaking stored blocks. | | `verbose_name` | From the class name: `"Image"` | The block's name in the admin. | | `description` | `""` | A short description in the list of blocks. | | `icon` | A generic block icon | A [Phosphor](https://phosphoricons.com) icon class, such as `"ph-bold ph-image"`. | | `template_name` | `"/blocks/.html"` | The template that renders the block. See [Render blocks in templates](#render-blocks-in-templates). | Blocks inherit the fields of their parent blocks, but not their `Meta`. ## Fields in blocks A block field validates its value the way the model field does: required fields, `max_length`, choices and validators all apply. HTML in an `HTMLField` is sanitized when the page is saved, as on a model. Relations, such as a `ForeignKey` or a `MediaField`, store the related object's primary key. There is no database constraint behind it: when the related object is deleted, the block keeps its key, and the admin and your templates read it as empty. Editors pick related objects of the selected tenant only. These fields cannot be used in a block: `ManyToManyField` and `ManyMediaField`, `FileField` and `ImageField` (upload to the media library and use a `MediaField` instead), and auto fields. A `BlockField` can, one level deep: see [Blocks inside blocks](#blocks-inside-blocks). System check [`content_studio.E018`](https://djangocontentstudio.org/docs/deployment.md#system-checks) reports the fields that cannot be used. ## Blocks inside blocks A block can have block fields of its own, for layouts such as columns or a section with a background: ```python # blog/models.py (a fragment; TextBlock and ImageBlock as above) class ColumnsBlock(Block): left = BlockField(blocks=[TextBlock, ImageBlock], blank=True, max_num=4) right = BlockField(blocks=[TextBlock, ImageBlock], blank=True, max_num=4) class Meta: description = "Two columns of blocks" icon = "ph-bold ph-columns" class LandingPage(models.Model): title = models.CharField(max_length=200) sections = BlockField(blocks=[TextBlock, ImageBlock, ColumnsBlock], max_num=20) def __str__(self): return self.title ``` On the edit page, each block field of the block is a list of blocks inside the block's card, with the same options as the page's own list. Editors drag blocks within a list and between lists, such as from one column to the other or out to the page, as long as the list takes that kind of block and has room for it. Blocks inside blocks go one level deep: the blocks of `left` and `right` cannot have block fields themselves. Their options, such as `max_num` and `blank`, apply per list. Leave out `blank=True` to require at least one block in each list. In the block's template, render a list with `render_blocks`: ```django {# blog/templates/blog/blocks/columns.html #} {% load content_studio %}
{% render_blocks value.left %}
{% render_blocks value.right %}
``` The related objects of the blocks inside blocks load together with those of the page, still with one query per related model. ## Limit the number of blocks `max_num` and `min_num` limit the total number of blocks: ```python sections = BlockField(blocks=[TextBlock, ImageBlock], min_num=2, max_num=20) ``` A block field without `blank=True` needs at least one block, like any required field. With `max_num`, the admin shows the count and stops adding blocks at the limit. ## Render blocks in templates The `render_blocks` tag renders each block with its block's template: ```django {# blog/templates/blog/landing_page.html #} {% load content_studio %}

{{ page.title }}

{% render_blocks page.sections %} ``` By default the template of a block is `/blocks/.html` for the app that defines the block. It gets the block as `block` (with `block.id` and `block.type`) and the block's field values as `value`, with related objects instead of primary keys: ```django {# blog/templates/blog/blocks/text.html #}
{{ value.body|safe }}
``` ```django {# blog/templates/blog/blocks/image.html #}
{{ value.caption }} {% if value.caption %}
{{ value.caption }}
{% endif %}
``` The related objects of all blocks load together, with one query per related model. To add to a block's template context, override `get_context`: ```python # blog/models.py (a fragment) class TextBlock(Block): body = HTMLField() @classmethod def get_context(cls, block): context = super().get_context(block) context["anchor"] = f"section-{block.id}" return context ``` To render the blocks yourself, loop over `blocks`. Each block also renders on its own: ```django {# blog/templates/blog/landing_page.html #} {% for block in page.sections.blocks %} {% if block.type == "image" %} {{ block.render }} {% else %}
{{ block.render }}
{% endif %} {% endfor %} ``` In Python, `page.sections` is a list of the stored blocks, and `page.sections.blocks` the same blocks with their values. ## Change blocks later Existing pages keep the blocks they were saved with: - **A new field** takes its default on existing blocks. A new required field without a default asks the editor for a value on the next save. - **A renamed block class** keeps its stored blocks when `Meta.name` keeps the old name. - **A removed block** stays in the stored data. The admin shows it as an unknown block that editors can remove, the page cannot be saved until they do, and templates leave it out. To find the pages that use a block, use a [JSON containment lookup](https://docs.djangoproject.com/en/stable/topics/db/queries/#contains), which works on PostgreSQL, MySQL and MariaDB: `LandingPage.objects.filter(sections__contains=[{"type": "image"}])`. ## Without a block field A block field is optional. With plain Django, a related model does the same job: a `Section` model with a foreign key to the page, a type field and a position field, edited as an [inline](https://djangocontentstudio.org/docs/registering-models.md#inlines) on the page's studio with `reorder_field = "position"`, so editors can drag sections into order (see [Reordering](https://djangocontentstudio.org/docs/list-page.md#reordering)). Each section is then a row in its own table, which you can query and protect with database constraints. ## Related pages - [Widgets](https://djangocontentstudio.org/docs/widgets.md) - [Media library](https://djangocontentstudio.org/docs/media-library.md) - [System checks](https://djangocontentstudio.org/docs/deployment.md#system-checks) Source: https://djangocontentstudio.org/docs/block-fields --- # Media library The media library is where editors upload and organize files: images, video, audio and documents, in folders. Fields such as `MediaField` then pick files from it. The library works with your own media models, or with the ready-made models Content Studio ships. ## Set it up with your own models Point the settings at a media item model and, optionally, a folder model: ```python # mysite/settings.py CONTENT_STUDIO = { "MEDIA_LIBRARY_MODEL": "blog.models.MediaItem", "MEDIA_LIBRARY_FOLDER_MODEL": "blog.models.MediaFolder", } ``` A media item model needs these fields: - `file`: the upload, a `FileField` or `ImageField`. - `name`: shown on the item and used by the search. - `type`: one of `"image"`, `"video"`, `"audio"` or `"file"`; it decides the preview. - `tags` (optional): a list of strings, such as a `JSONField`, an `ArrayField` or the built-in `TagField`. Searched together with the name. - `alt_text` (optional): a short text alternative for the file. - `metadata` (optional): a `JSONField` for the [metadata](#metadata) read from the file. - `properties` (optional): a `JSONField` for the [properties](#properties) editors set, such as an image's focal point. The fields above apply to every type of media. A value that applies to only some types of media, such as an image's focal point, belongs in `metadata` when it is read from the file, and in `properties` when editors set it. A folder model needs a `name` and a `parent` foreign key to itself. System check `content_studio.W007` reports missing fields. Without the `MEDIA_LIBRARY_MODEL` setting, the media library does not appear in the admin. ## Or use the built-in models The `content_studio.media_library` app ships media item and folder models that follow these rules, with migrations. Add the app and point the settings at its models: ```python # mysite/settings.py INSTALLED_APPS = [ # ... "content_studio", "content_studio.media_library", ] CONTENT_STUDIO = { "MEDIA_LIBRARY_MODEL": "content_studio.media_library.models.Media", "MEDIA_LIBRARY_FOLDER_MODEL": "content_studio.media_library.models.Folder", } ``` With [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), the media library is shared by every tenant. Deleting an item also removes its file from storage, and replacing an item's file removes the previous one. With your own models, a replaced file is removed as well; a deleted item's file stays unless your model removes it, for example in a `post_delete` receiver. ## Working in the media library Editors upload files with the upload button or by dragging them onto the library, and add tags in the same step when the model has a `tags` field. They move files between folders, edit their details, and copy or download a file from its menu. The library has three views: a **grid** of thumbnails, a **list** and a **table** with sortable columns for name, type and size. The admin remembers the view, the filters and the sort order per media model. The search and the current folder are part of the URL, so a filtered view survives a reload and the back button. The search matches names and tags. Two filters narrow the items further: - **Type**: image, video, audio or file. - **Tags**: any of the selected tags. With folders, the search covers the current folder or the whole library, set with the toggle next to the search box. Each folder shows how many items it holds. Browsing the media library, also from a media field, needs the media model's view or change permission; that permission also opens the folders. Select items to delete them or move them to another folder. Deleting needs the model's delete permission, moving its change permission. ## Uploads The media library accepts the file types in [`MEDIA_LIBRARY_ALLOWED_EXTENSIONS`](https://djangocontentstudio.org/docs/settings.md#media_library_allowed_extensions), up to [`MEDIA_LIBRARY_MAX_UPLOAD_SIZE`](https://djangocontentstudio.org/docs/settings.md#media_library_max_upload_size) (50 MB by default). It rejects files whose content is markup, such as HTML or SVG: served from your own domain, such a file could run scripts in your editors' sessions. ## Thumbnails The admin shows images in two sizes: small thumbnails in the media library, pickers and list pages, and large ones in the cover image widget, cropping and the focal point. With the `images` extra installed, Content Studio makes both from the uploaded image: ```bash pip install "django-content-studio[images]" ``` Thumbnails are saved in the image's own storage, in the folder set by [`THUMBNAIL_FOLDER`](https://djangocontentstudio.org/docs/settings.md#thumbnail_folder). Content Studio makes them when an image is uploaded, replaced or cropped, and the first time an image you already have is shown. It removes them with the image. Making a thumbnail takes a moment, so a library of existing images is slow the first time the admin shows it. Make the missing thumbnails in advance: ```bash python manage.py content_studio_thumbnails ``` Pass `--all` to make every thumbnail again. With several worker processes or servers, configure a shared cache for [`THUMBNAIL_CACHE`](https://djangocontentstudio.org/docs/settings.md#thumbnail_cache), such as Redis, Memcached or the database cache. Without Pillow, with `THUMBNAIL_FOLDER` set to `None`, or for a file it cannot read, the admin shows the image file itself. To serve thumbnails another way, override [`get_thumbnail`](#customize-with-site-hooks). ## Image sizes Thumbnails are for the admin. For your site, apps and other consumers, name the sizes you need in [`IMAGE_SIZES`](https://djangocontentstudio.org/docs/settings.md#image_sizes), and Content Studio makes every image in each of them: ```python CONTENT_STUDIO = { # ... "IMAGE_SIZES": { "card": {"width": 600, "height": 400, "fit": "cover"}, "hero": {"width": 1920}, "share": {"width": 1200, "height": 630, "fit": "cover", "format": "jpeg"}, }, } ``` Each size takes these options: | Option | Default | Meaning | |--------|---------|---------| | `width`, `height` | | The box in pixels. Give one or both; a side left out is free. | | `fit` | `"contain"` | `"contain"` scales the image to fit inside the box. `"cover"` fills the box exactly, cut around the [focal point](#focal-points); it needs both sides. | | `format` | `"webp"` | `"webp"`, `"jpeg"`, `"png"`, or `"original"` to keep the uploaded format (WebP for other formats). | | `quality` | `82` | The quality of WebP and JPEG images, from 1 to 100. | | `upscale` | `False` | Whether a smaller image is enlarged to the size. Without it, a small image gives a smaller size of the same shape. | Sizes need the `images` extra and a media model with a metadata field (the built-in `Media` model has one). Content Studio makes them after an image is uploaded, replaced, cropped or reset, and makes the `"cover"` sizes again when its focal point moves. They are saved in the image's own storage, in the folder set by [`IMAGE_SIZE_FOLDER`](https://djangocontentstudio.org/docs/settings.md#image_size_folder) (`"content_studio/sizes"` by default), and recorded in the item's metadata. A size's file name changes whenever its image, its options or its focal point change, so you can cache the files for as long as you like. Sizes that are replaced, and the sizes of a deleted image, are removed. ### Use the sizes `get_image_sizes` returns every size of an image, with its URL and the width and height it has: ```python from content_studio.media_library.sizes import get_image_sizes get_image_sizes(article.cover) # {"card": {"url": "/media/content_studio/sizes/...-card-....webp", # "width": 600, "height": 400, "ready": True}, # "hero": {...}, "share": {...}} ``` In templates, use the `image_url` and `image_size` tags of the `content_studio` library: ```django {% load content_studio %} {{ article.cover.alt_text }} {% image_size article.cover "card" as card %} ``` A size that is not made yet is the image's own file, with `ready` set to `False`: an image is never missing, only larger for a moment. An item that is not an image has no sizes (`{}`), and an unknown size name gives `None` in `image_size` and an empty string in `image_url`. ### Making sizes in the background Content Studio makes the sizes in a [Django task](https://docs.djangoproject.com/en/stable/topics/tasks/). Without a `TASKS` setting, Django runs it right after the change is saved, in the same request. To keep uploads and crops fast, configure a task backend that runs it in a worker, such as [Huey](https://djangocontentstudio.org/docs/dashboard.md#huey). On Django 5.2, install the `django-tasks` backport to use a backend; without it, the sizes are made in the request. After you add or change a size, existing images are out of date: they show their own file until their sizes are made. Make them, and remove the sizes you no longer configure, with: ```bash python manage.py content_studio_image_sizes ``` Pass `--all` to make every size again. ## Cropping and focal points A crop removes what must never be shown, such as a watermark or a bystander; the focal point decides which part of an image stays in view where your site shows it smaller or in another shape. ### Cropping Images can be cropped once Content Studio can read them: install the `images` extra, and give your media model a [metadata](#metadata) field (the built-in `Media` model has one): ```bash pip install "django-content-studio[images]" ``` Editors crop an image from its details. A crop is cut from the image at full size and is never resized. The crop becomes the item's file, so `{{ item.file.url }}` and everything else that shows the file shows the crop. The original is kept: cropping again starts from the original, and **Reset to original** puts it back. An image has one original and one crop. The item's metadata names the original and the crop, as percentages of the original: ```python { "width": 1200, "height": 800, "crop": { "original": "3f1c0b8e-…", "width": 4000, "height": 3000, "box": {"x": 10.0, "y": 20.0, "width": 30.0, "height": 26.67}, }, } ``` `width` and `height` are the crop's size; `crop` holds the original's storage name and size. Replacing an item's file removes its crop and its original, and so does deleting the item. Animated images cannot be cropped. To crop another way, override `crop_image` on your site. It receives the item and the editor's selection, as percentages of the image: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite class BlogStudioSite(ContentStudioSite): def crop_image(self, obj, crop_info): # {"x": "10.00", "y": "20.00", "width": "50.00", "height": "40.00", "unit": "%"} # Store the selection, for a template tag of your own to apply. obj.properties = {**obj.properties, "crop": crop_info} obj.save(update_fields=["properties"]) admin_site = BlogStudioSite() ``` Your method decides what a crop does: create a derivative, change the file or store the selection. Your site's method also turns cropping on without Pillow or a metadata field. Override `reset_crop(obj)` as well to offer **Reset to original** for your own crops. ### Focal points A focal point is one of the image's [properties](#properties): editors click the image to set it. The [cover image widget](https://djangocontentstudio.org/docs/widgets.md#cover-images) sets the same focal point from the edit page. The focal point is relative to the image as it is shown, so the built-in crop and reset move it along; a focal point outside a new crop moves to the crop's nearest edge. ## Metadata The media library can store facts about each file, read once when the file is uploaded or replaced: an image's width and height, or a video's duration. The admin shows them in the file's details, and the [cover image widget](https://djangocontentstudio.org/docs/widgets.md#cover-images) uses an image's size to warn about images that are too small. 1. Give your media item model a `JSONField` named `metadata`. The built-in `Media` model has one. To use a field by another name, name it in [`MEDIA_METADATA_ATTR`](https://djangocontentstudio.org/docs/settings.md#media_metadata_attr): ```python # blog/models.py from django.db import models class MediaItem(models.Model): class Type(models.TextChoices): IMAGE = "image", "Image" VIDEO = "video", "Video" AUDIO = "audio", "Audio" FILE = "file", "File" file = models.FileField(upload_to="media/") name = models.CharField(max_length=255, blank=True) type = models.CharField(max_length=10, choices=Type.choices, default=Type.FILE) metadata = models.JSONField(default=dict, blank=True, editable=False) def __str__(self): return self.name ``` 2. Install Content Studio with the `images` extra, which adds [Pillow](https://python-pillow.org/) to read the size of images: ```bash pip install "django-content-studio[images]" ``` 3. Read the metadata of the files you already have: ```bash python manage.py content_studio_media_metadata ``` The command reads the items without metadata. Pass `--all` to read every item again, for example after you change what is read. Images get `{"width": 1600, "height": 900}`, in pixels as the image is shown, after its EXIF orientation. Other files get no metadata by default: a video's duration and frame rate need a tool such as [FFmpeg](https://ffmpeg.org/)'s `ffprobe`. Override `get_media_metadata` on your site to read them. It receives the uploaded file and the item's type (`"image"`, `"video"`, `"audio"` or `"file"`) and returns a dictionary of JSON values: ```python # mysite/studio.py import json import subprocess import tempfile from content_studio.admin import ContentStudioSite class BlogStudioSite(ContentStudioSite): def get_media_metadata(self, file, type): if type != "video": return super().get_media_metadata(file, type) # ffprobe reads from a path: copy the upload to a temporary file. with tempfile.NamedTemporaryFile() as copy: file.seek(0) for chunk in file.chunks(): copy.write(chunk) copy.flush() file.seek(0) result = subprocess.run( [ "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,r_frame_rate:format=duration", "-of", "json", copy.name, ], capture_output=True, check=True, text=True, ) probe = json.loads(result.stdout) stream = probe["streams"][0] frames, seconds = stream["r_frame_rate"].split("/") return { "width": stream["width"], "height": stream["height"], "duration": float(probe["format"]["duration"]), "frame_rate": round(int(frames) / int(seconds), 2), } admin_site = BlogStudioSite() ``` The admin shows `width` and `height` in pixels, `duration` in seconds as minutes and seconds, and `frame_rate` in frames per second. Other keys are stored for your own templates and code. The method runs during the upload request, so keep it fast; an exception in it fails the upload. ## Properties Properties are values editors set for some types of media, stored in a `JSONField` on the media item. The built-in `Media` model has a `properties` field; give your own media model a `JSONField` named `properties`, or name another one in [`MEDIA_PROPERTIES_ATTR`](https://djangocontentstudio.org/docs/settings.md#media_properties_attr): ```python # blog/models.py from django.db import models class MediaItem(models.Model): class Type(models.TextChoices): IMAGE = "image", "Image" VIDEO = "video", "Video" AUDIO = "audio", "Audio" FILE = "file", "File" file = models.FileField(upload_to="media/") name = models.CharField(max_length=255, blank=True) type = models.CharField(max_length=10, choices=Type.choices, default=Type.FILE) properties = models.JSONField(default=dict, blank=True) def __str__(self): return self.name ``` The admin sets these properties: - **`focal_point`**: the part of an image that stays in view when it is cropped, as `{"x": 0-100, "y": 0-100}`, percentages of the image's width and height from its top left. `None` when it is removed. Unlike [metadata](#metadata), properties are kept when an item's file is replaced. Other keys are kept as they are, so your own code can store values there too. Read them in templates like any dictionary: ```django {# blog/templates/blog/article_detail.html #} {% with point=article.cover.properties.focal_point %} {{ article.cover.alt_text }} {% endwith %} ``` ## Customize with site hooks Five methods on the site change how the media library behaves. Add them to your site subclass: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite class BlogStudioSite(ContentStudioSite): def get_thumbnail(self, obj, size="small"): return f"https://images.example.com/{obj.file.name}?size={size}" def get_media_serializer_fields(self, media_model): return ["id", "name", "type", "tags", "file", "folder"] def crop_image(self, obj, crop_info): # Crop obj's file the way your project needs. ... admin_site = BlogStudioSite() ``` - **`get_thumbnail(obj, size="small")`** returns the thumbnail URL of an image, `"small"` or `"large"`. By default, a [thumbnail](#thumbnails) made by Content Studio, or the file's own URL. Override it to serve thumbnails from an image service or CDN. - **`get_media_serializer_fields(media_model)`** returns the fields of a media item the admin works with. By default, all fields; leave out fields editors should not see. - **`crop_image(obj, crop_info)`** crops an image. By default, the [built-in crop](#cropping-and-focal-points); override it to crop another way. - **`reset_crop(obj)`** undoes a crop. By default, it puts the original back that the built-in crop kept. - **`get_media_metadata(file, type)`** returns the [metadata](#metadata) to store for an uploaded file. Source: https://djangocontentstudio.org/docs/media-library --- # Dashboard The dashboard is the admin's start page. You compose it in Python from widgets: statistics, lists of content, recent activity and scheduled tasks. ## Compose a dashboard Most widgets get their data from a `get_data(request)` method you write. Subclass the built-in widget and implement it: ```python # blog/dashboard.py from content_studio.dashboard.content_list import ContentListWidget from content_studio.dashboard.statistic import StatisticWidget from .models import Article class PublishedArticlesWidget(StatisticWidget): def get_data(self, request): return { "title": "Published articles", "value": Article.objects.filter(status="published").count(), } class LatestArticlesWidget(ContentListWidget): def get_data(self, request): articles = Article.objects.order_by("-published_at")[:5] return { "title": "Latest articles", "content": [ {"model": "blog.article", "id": str(article.pk), "title": article.title} for article in articles ], } ``` Then give the site a `Dashboard` with the widgets, in the order they appear: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.dashboard import Dashboard from content_studio.dashboard.activity_log import ActivityLogWidget from blog.dashboard import LatestArticlesWidget, PublishedArticlesWidget class BlogStudioSite(ContentStudioSite): dashboard = Dashboard( widgets=[ PublishedArticlesWidget(), LatestArticlesWidget(), ActivityLogWidget(), ] ) admin_site = BlogStudioSite() ``` Point the [`ADMIN_SITE`](https://djangocontentstudio.org/docs/settings.md#admin_site) setting at the site: ```python # mysite/settings.py CONTENT_STUDIO = { "ADMIN_SITE": "mysite.studio.admin_site", } ``` `get_data` returns a dictionary with the widget's data, or a [Django REST Framework](https://www.django-rest-framework.org/api-guide/serializers/) serializer that validates it. A widget's `col_span` sets how many grid columns it spans. A widget loads its data when the dashboard opens and when the editor returns to the admin. Set `refresh_interval` to reload it every so many seconds while it is shown, such as the length of a task queue: ```python class QueuedTasksWidget(StatisticWidget): refresh_interval = 10 def get_data(self, request): return {"title": "Queued tasks", "value": count_queued_tasks()} ``` `ScheduledTasksWidget` reloads every 10 seconds by default. System check `content_studio.E029` reports an interval that is not a positive number. The start page is not the only dashboard: a [`DashboardPage`](https://djangocontentstudio.org/docs/extensions.md#dashboardpage) adds a page to the admin with a dashboard of its own. ## Built-in widgets - **`StatisticWidget`**: one number with a title. `get_data` returns `value` and, optionally, `title`, `prefix`, `suffix`, a `trend` (a percentage) and a `trend_sentiment` (`"positive"`, `"negative"` or `"default"`). See [Statistics](#statistics) for the formats and extras. - **`ContentListWidget`**: a list of links to content. `get_data` returns a `title`, an optional `description` and `content`: items with the `model` label, the object's `id`, a `title` and, optionally, a `description`, a `status` shown as a badge, a `status_variant` that colors it (`"default"`, `"success"`, `"warning"`, `"info"` or `"danger"`), the URL of a `thumbnail` and a `timestamp`, shown relative to now. - **`ActivityLogWidget`**: the five latest changes made in the admin. It needs no configuration and only shows changes to models the editor may view. With [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), changes to a tenant's content only show in that tenant, and deletions of that content are not shown. - **`ScheduledTasksWidget`**: the status of scheduled tasks, from your task runner. `get_data` returns `tasks`: items with a `title`, `last_run_at`, `next_run_at`, `duration` and a `status` (`"RUNNING"`, `"SCHEDULED"`, `"SUCCESS"` or `"FAILURE"`), and optionally a `description` and an `error_message`. Times and durations that are `None` are left out. - **`WebhookDeliveriesWidget`**: each webhook with its latest delivery, from the [webhooks app](https://djangocontentstudio.org/docs/webhooks.md#a-deploy-button-and-a-dashboard-widget). - **`NotificationsWidget`**: the editor's latest notifications, from the [notifications app](https://djangocontentstudio.org/docs/notifications.md#a-dashboard-widget). - **`SpacingWidget(col_span)`**: empty space, to align the grid. Your own widgets subclass one of these: the admin can only show the built-in widget types. System check `content_studio.W012` reports a widget that does not. ## Statistics A `StatisticWidget` shows its value compact and in the editor's language: 1,234 shows as 1.2K and 45,940 as 45.9K, with the exact value on hover. Set `number_format` to show it otherwise: - `"compact"` (the default): large numbers shortened, with one decimal. - `"full"`: every digit, grouped (45,940). - `"percent"`: the value as a percentage, so 91 shows as 91%. - `"currency"`: an amount in `currency`, a three-letter code such as `"EUR"`; compact from 1,000 on. The value may be a whole number or a decimal. A statistic also takes these optional fields: - `icon`: a [Phosphor](https://phosphoricons.com/) icon class, such as `"ph-bold ph-eye"`, shown next to the title. - `trend_label`: what the `trend` compares with, such as "vs last month". - `footnote`: a short line under the value. - `series`: a list of numbers, oldest first, drawn as a sparkline. - `max_value`: draws a meter of the value against it, such as a goal. ```python # blog/dashboard.py from datetime import timedelta from django.utils import timezone from content_studio.dashboard.statistic import StatisticWidget from .models import Article class RecentlyPublishedWidget(StatisticWidget): def get_data(self, request): today = timezone.localdate() published = Article.objects.filter(status="published") days = [today - timedelta(days=n) for n in range(29, -1, -1)] return { "title": "Published, last 30 days", "value": published.filter(published_at__date__gte=days[0]).count(), "icon": "ph-bold ph-check-circle", "trend": 12.5, "trend_sentiment": "positive", "trend_label": "vs last month", # Articles published per day, oldest first. "series": [published.filter(published_at__date=day).count() for day in days], } ``` ## Task dashboard `TaskDashboard` is a [dashboard page](https://djangocontentstudio.org/docs/extensions.md#dashboardpage) with the state of your task backend: a statistic tile per number, the periodic tasks with their latest run, and the latest runs. Content Studio does not know your backend. You connect it with an adapter: a subclass of `TaskAdapter` that implements the methods your backend supports. ```python # mysite/tasks_dashboard.py from content_studio.dashboard.tasks import ( TaskAdapter, TaskRow, TaskStatistic, TaskStatus, ) class QueueAdapter(TaskAdapter): statistics = ("queued", "running", "succeeded", "failed") def get_statistics(self, request): counts = my_queue.counts() return {key: TaskStatistic(counts[key]) for key in self.statistics} def get_recent_tasks(self, request, limit): return [ TaskRow( title=run.name, status=TaskStatus.SUCCESS if run.ok else TaskStatus.FAILURE, last_run_at=run.finished_at, duration=run.seconds, error_message=run.error, ) for run in my_queue.latest_runs(limit) ] ``` Add the page to the site's extensions, with a menu link to its path: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.dashboard.tasks import TaskDashboard from content_studio.extensions import UserMenuLink from .tasks_dashboard import QueueAdapter class BlogStudioSite(ContentStudioSite): extensions = [ TaskDashboard(adapter=QueueAdapter()), UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"), ] ``` The page lives at `tasks/` with the title "Tasks"; pass `path`, `title` and `description` to change them. Its widgets reload every 10 seconds. An adapter implements any of these methods. The page leaves out the widgets of the ones it does not implement. - **`get_statistics(request)`**: a dictionary from statistic key to a `TaskStatistic(value, trend=None)`, for every key in the adapter's `statistics`. The keys are `queued`, `scheduled`, `running`, `succeeded`, `failed` and `retried`; tiles show in the order of `statistics`. `succeeded`, `failed` and `retried` count over the adapter's `window` (24 hours by default), which shows in their title. `trend` is a change in percent; a rise in failures shows as bad news. - **`get_periodic_tasks(request)`**: a list of `TaskRow`, one per task that runs on a schedule, with its latest run and its next. - **`get_recent_tasks(request, limit)`**: a list of at most `limit` `TaskRow`, the latest first. A `TaskRow` has a `title` and a `status` (a `TaskStatus`), and optionally a `description`, `last_run_at`, `next_run_at`, a `duration` in seconds and an `error_message`. > **Caution** > > Task names and error messages can reveal how your project works. Only > superusers see the task dashboard; override `is_visible(request)` on a > subclass to show it to more users. The page is built from two widgets that also work on any other dashboard, such as the start page: `TaskStatisticWidget(adapter, statistic)` and `TaskListWidget(adapter, kind)`, where `kind` is `"periodic"` or `"recent"`. Override `get_widgets()` on a `TaskDashboard` subclass to change its layout. When a site uses two adapters of the same class, such as for two queues, override the adapter's `get_id()` so their widgets get different IDs. An adapter can report problems with its setup by overriding `check()`: it returns a list of `(check_id, message)` pairs, which the system checks show as warnings. ### Huey Content Studio ships an adapter for [Huey](https://huey.readthedocs.io/): `HueyAdapter` in `content_studio.contrib.huey`. Install it with the `huey` extra: ```bash pip install "django-content-studio[huey]" ``` Add the page with a menu link, and Huey's stats app to `INSTALLED_APPS`: ```python # mysite/settings.py INSTALLED_APPS = [ # ... "huey.contrib.djhuey", "huey.contrib.djhuey.stats", "content_studio", ] ``` ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.contrib.huey import HueyAdapter from content_studio.dashboard.tasks import TaskDashboard from content_studio.extensions import UserMenuLink class BlogStudioSite(ContentStudioSite): extensions = [ TaskDashboard(adapter=HueyAdapter()), UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"), ] ``` The adapter reports djhuey's `HUEY`; pass `HueyAdapter(huey=...)` for another Huey instance. The page shows: - **Statistics**: the queued and scheduled tasks, from Huey's storage; and the running tasks and the succeeded, failed and retried runs of the last 24 hours, from the stats app. Pass `statistics` to show fewer, and `window` to count over another period. - **Periodic tasks**: every periodic task with its latest run and its next one. Huey cannot describe a schedule, so the description is the first line of the task's docstring, or its module. Override `get_task_description(task_class)` to describe the schedules yourself. - **Recent runs**: the latest runs the workers finished. Runs that ended without completing, such as revoked, expired or locked ones, show as failed, with the reason. Django tasks run by Huey's Django tasks backend, such as [webhook](https://djangocontentstudio.org/docs/webhooks.md) deliveries, show under their own name. Huey's stats app records what the workers do; without it, the page only shows the queued and scheduled tasks, and the periodic tasks without their runs, and system check `content_studio.W020` warns. The stats app keeps 48 hours and at most 2000 events by default (its `HUEY_STATS` setting); on a busy queue, the counts cover less than the window. ## Two widgets of the same class Give each an explicit `widget_id` (a UUID) to tell them apart; `manage.py check` reports duplicates. Source: https://djangocontentstudio.org/docs/dashboard --- # Extensions Extensions add links, buttons and pages to the admin's menus: a link to an external tool, a button that runs your code, another web application shown inside the admin, or a page with a dashboard of its own. For your website's visitor statistics, see [Analytics](https://djangocontentstudio.org/docs/analytics.md). ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.extensions import IFramePage, MainMenuLink class BlogStudioSite(ContentStudioSite): extensions = [ MainMenuLink( url="https://status.example.com", label="Status", icon="ph-bold ph-heartbeat", ), IFramePage( path="reports/", iframe_url="https://metabase.example.com/dashboard/1", ), ] admin_site = BlogStudioSite() ``` ## MainMenuLink A link in the admin's main menu. It takes a `url`, a `label` and an `icon`, and optionally a `color` and a `weight`. The `url` is a page of the admin, such as `"/reports/"` for an `IFramePage`, or an `http(s)` URL, which opens in a new tab. The menu orders its links and buttons by `weight`, lowest first; with equal weights, which is the default of `0`, they keep their order in `extensions`. To show a link to some users only, subclass it and override `is_visible(request)`: ```python class ReportsLink(MainMenuLink): def is_visible(self, request): return request.user.is_superuser ``` ## MainMenuButton A button in the admin's main menu that runs your code on the server, such as clearing a cache or starting a deployment. Subclass `MainMenuButton` and implement `handle(request)`: ```python # mysite/studio.py from django.core.cache import cache from content_studio.admin import ContentStudioSite from content_studio.extensions import MainMenuButton class ClearCacheButton(MainMenuButton): label = "Clear cache" icon = "ph-bold ph-broom" confirm = "Clear the cache for every visitor?" def is_visible(self, request): return request.user.is_superuser def handle(self, request): cache.clear() return "The cache is cleared." class BlogStudioSite(ContentStudioSite): extensions = [ClearCacheButton()] admin_site = BlogStudioSite() ``` - `label`, `icon`, `color` and `weight`: as for `MainMenuLink`. - `confirm`: a question to confirm before the button runs. - `is_visible(request)`: who sees the button. By default every staff user does. Content Studio checks it again when the button runs. `handle` returns what the editor sees: a string as a notification, or a [`ButtonResult`](https://djangocontentstudio.org/docs/edit-page.md#buttons) with a `message` and a `value` that appears once in a dialog with a copy button. The options can also be passed as arguments, such as `ClearCacheButton(label="Empty cache")`. Record the changes the button makes with `record_change`, so they appear in the objects' history. The [webhooks app](https://djangocontentstudio.org/docs/webhooks.md#a-deploy-button-and-a-dashboard-widget) ships one ready to use: `DeployButton`. ## UserMenuLink and UserMenuButton A link or a button in the user menu, the menu under the user's name, between the theme and logging out. They take the same options as `MainMenuLink` and `MainMenuButton`, including `is_visible(request)`, and the user menu orders them by `weight` the same way: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.extensions import UserMenuLink class TaskQueueLink(UserMenuLink): def is_visible(self, request): return request.user.is_superuser class BlogStudioSite(ContentStudioSite): extensions = [ UserMenuLink( url="https://help.example.com", label="Help", icon="ph-bold ph-question", ), TaskQueueLink(url="/tasks/", label="Task queue", icon="ph-bold ph-queue"), ] admin_site = BlogStudioSite() ``` ## IFramePage A page in the admin that shows another web page in an iframe. `path` is the page's address under the admin's URL prefix, `iframe_url` the page it shows. The embedded page runs in a sandbox: it can run scripts, keep its origin, submit forms, open windows and offer downloads, but it cannot navigate the admin away, show modal dialogs or lock the pointer. The `sandbox` option takes another string of [sandbox tokens](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox) to tighten or widen it, or `None` to embed the page without a sandbox: ```python IFramePage( path="reports/", iframe_url="https://metabase.example.com/dashboard/1", sandbox="allow-scripts allow-same-origin", ) ``` You choose the page the iframe shows, so the admin trusts it to that extent: a page that may navigate the admin or open dialogs needs `sandbox=None`. ## DashboardPage A page in the admin with a [dashboard](https://djangocontentstudio.org/docs/dashboard.md) of its own, such as a screen of task queue statistics next to the start page. `path` is the page's address under the admin's URL prefix, `title` its heading and `dashboard` a `Dashboard` with its widgets. An optional `description` shows under the title. Link to the page with a menu link to its path. ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.dashboard import Dashboard from content_studio.extensions import DashboardPage from blog.dashboard import LatestArticlesWidget, PublishedArticlesWidget class ReportsPage(DashboardPage): def is_visible(self, request): return request.user.is_superuser class BlogStudioSite(ContentStudioSite): extensions = [ ReportsPage( path="reports/", title="Reports", description="How the blog is doing.", dashboard=Dashboard( widgets=[PublishedArticlesWidget(), LatestArticlesWidget()] ), ), ] admin_site = BlogStudioSite() ``` `is_visible(request)` decides who sees the page. By default every staff user does. Content Studio checks it again whenever one of the page's widgets loads its data, so the widgets of a hidden page show nothing. The page's widgets work as on the start page. Their IDs must be unique across the start page and every dashboard page; system check `content_studio.E004` reports duplicates. A path that one of the admin's own pages uses, such as `content/` or `media-library/`, is reported by system check `content_studio.E028`. ## Two identical extensions Two extensions with the same settings need an explicit `extension_id` (a UUID) to tell them apart; `manage.py check` reports duplicates. Source: https://djangocontentstudio.org/docs/extensions --- # Analytics Show your website's visitor statistics in the admin: a dedicated analytics page, the statistics of the object being edited, and widgets for the dashboard. Content Studio reads them from your analytics tool through a provider. It ships one for [Umami](https://umami.is), and you can write your own for another tool. ## Set up analytics Add an `AnalyticsExtension` with a provider to your site's `extensions`, and an `AnalyticsPage` with a link to it: ```python # mysite/studio.py from django.conf import settings from content_studio.admin import ContentStudioSite from content_studio.analytics import ( AnalyticsExtension, AnalyticsLink, AnalyticsPage, UmamiProvider, ) class BlogStudioSite(ContentStudioSite): extensions = [ AnalyticsExtension( provider=UmamiProvider( website_id=settings.UMAMI_WEBSITE_ID, api_key=settings.UMAMI_API_KEY, ), ), AnalyticsPage(), AnalyticsLink(), ] admin_site = BlogStudioSite() ``` The main menu then shows **Analytics**, which opens the analytics page. Every active staff user sees the analytics; to narrow that down, see [Who sees analytics](#who-sees-analytics). The `AnalyticsExtension` takes: - `provider`: the provider that reads the statistics. - `cache_timeout`: how long, in seconds, the statistics are cached in Django's cache, so that editors opening the same report do not each ask the analytics tool. `300` by default. - `realtime_cache_timeout`: the same for the number of visitors online now. `30` by default. ## Umami `UmamiProvider` reads a website's statistics from Umami Cloud or from your own Umami server, with an API key: ```python # Umami Cloud: create an API key under Settings > API keys. UmamiProvider(website_id="4fb7...", api_key="api_...") # Your own Umami server: an API key from the server's settings. UmamiProvider( url="https://umami.example.com", website_id="4fb7...", api_key="umami_...", ) ``` It takes: - `website_id`: the website's ID, from its settings in Umami. - `api_key`: the API key. Keep it out of your code, in a setting read from the environment. - `url`: your Umami server's address. Leave it out for Umami Cloud. - `dashboard_url`: where **Open in Umami** goes. On your own server it is the website's page there; for Umami Cloud, set it to show the button. Umami reports visitors, pageviews, visits, bounce rate and visit duration, and breaks visitors down by page, entry and exit page, referrer, channel, campaign, country, region, city, device, browser and operating system. To show each tenant its own website on a [multi-tenant](https://djangocontentstudio.org/docs/multi-tenancy.md) site, subclass the provider and override `get_website_id(request)`. ## The analytics page The analytics page compares the site's metrics with the period before, charts them over time, and lists the top pages, sources, locations and devices. The period is today, the last 7, 30 or 90 days or 12 months, or a range of dates. A page's statistics show when you open the page in the top pages, or follow **Full report** from an object. The period and the page are in the address, so a report can be shared. `AnalyticsPage` takes a `path` (`"analytics/"` by default), a `title` and a `description`. ### Links `AnalyticsLink` adds the analytics page to the main menu and `AnalyticsUserMenuLink` to the user menu. Both take the options of a [`MainMenuLink`](https://djangocontentstudio.org/docs/extensions.md#mainmenulink), and link to the analytics page unless you give them a `url`: ```python from content_studio.analytics import AnalyticsUserMenuLink extensions = [ # ... AnalyticsPage(), AnalyticsUserMenuLink(label="Statistics"), ] ``` ## The statistics of an object The `Analytics` component shows the statistics of the edited object's page on your site. In `edit_sidebar` it shows four numbers, a chart and the top sources; in `edit_main` the full report: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from content_studio.analytics import Analytics @register(Article) class ArticleStudio(ModelStudio): edit_main = ["title", "body"] edit_sidebar = [ "published_at", Analytics(path_field="slug", path_prefix="/blog/", path_suffix="/"), ] ``` The component finds the object's page from `path_prefix`, the value of `path_field` and `path_suffix`, so the article with the slug `slow-trains` above is `/blog/slow-trains/`. Without `path_field`, it uses the path of the object's `get_absolute_url()`. An object without a page, such as an article without a slug, shows a note instead. With `path_field`, the analytics page's top pages and the top pages widget link to the objects they show, for the editors who may view them. `Analytics` also takes a `title`, a `layout` (`"compact"` or `"full"` to choose one wherever it is placed) and a `default_range` (`"30d"` by default). For pages that a field cannot describe, override `get_path(obj, request)`, and `get_objects(paths, queryset, request)` to keep the links: ```python class ArticleAnalytics(Analytics): def get_path(self, obj, request): return f"/{obj.published_at:%Y/%m}/{obj.slug}/" def get_objects(self, paths, queryset, request): slugs = {path.rstrip("/").rsplit("/", 1)[-1]: path for path in paths} return { slugs[article.slug]: article for article in queryset.filter(slug__in=slugs) } ``` ## Dashboard widgets Two widgets show analytics on the [dashboard](https://djangocontentstudio.org/docs/dashboard.md): ```python from content_studio.analytics import ( AnalyticsStatisticWidget, AnalyticsTopPagesWidget, ) from content_studio.dashboard import Dashboard dashboard = Dashboard( widgets=[ AnalyticsStatisticWidget(metric="visitors"), AnalyticsStatisticWidget(metric="bounce_rate", range="7d"), AnalyticsTopPagesWidget(limit=5), ], ) ``` `AnalyticsStatisticWidget` shows one metric (`visitors`, `pageviews`, `visits`, `bounce_rate` or `visit_duration`) with its change against the period before and a chart. `AnalyticsTopPagesWidget` lists the most visited pages, linked to their objects. Both take a `range` (`"today"`, `"7d"`, `"30d"`, `"90d"` or `"12mo"`; `"30d"` by default) and a `title`, and refresh every five minutes. ## Who sees analytics Every active staff user sees the analytics page, the component and the widgets. To show them to some users only, subclass the extension and override `is_visible(request)`: ```python class StaffAnalytics(AnalyticsExtension): def is_visible(self, request): return request.user.groups.filter(name="Marketing").exists() ``` The `Analytics` component also needs permission to view the object. ## When the analytics tool is down Content Studio asks the analytics tool when an editor opens a report; the Umami provider waits five seconds for an answer. When none comes, the reports say the statistics are unavailable and offer to try again; the rest of the admin is unaffected. ## Write a provider To read the statistics from another tool, such as Plausible or Matomo, subclass `AnalyticsProvider` and implement what the tool supports: ```python from content_studio.analytics import ( AnalyticsError, AnalyticsProvider, BreakdownRow, MetricValue, TimeseriesPoint, ) class PlausibleProvider(AnalyticsProvider): name = "Plausible" metrics = ("visitors", "pageviews", "bounce_rate", "visit_duration") timeseries_metrics = ("visitors", "pageviews") breakdowns = ("pages", "referrers", "countries", "devices") def get_summary(self, request, query): # query.start and query.end are aware datetimes, query.path the page # (or None), query.previous the period before. ... return {"visitors": MetricValue(1200, previous=1000), ...} def get_timeseries(self, request, query, metric): # One point per query.buckets(), by query.interval (hour, day or month). ... return [TimeseriesPoint(date=bucket, value=40, previous=30), ...] def get_breakdown(self, request, query, dimension, limit): ... return [BreakdownRow(key="/blog/slow-trains/", value=310), ...] def get_realtime(self, request): return 7 def get_dashboard_url(self, request, query): return "https://plausible.io/example.com" ``` Raise `AnalyticsError` when the tool cannot be reached, and the reports say the statistics are unavailable. The page and the component leave out what a provider does not implement, such as the live count without `get_realtime`. When one provider serves several websites, override `get_cache_key(request)` so that they are cached apart, and `check()` to report a missing setting as a system check warning. Source: https://djangocontentstudio.org/docs/analytics --- # Access control Content Studio uses the permission system of the classic Django admin. Only active staff users can log in, editors need Django's model permissions to see and change content, and the permission hooks on a studio can restrict access further, down to single objects. ## Model-level permissions Each studio checks access through four permission methods. By default they check Django's model permissions: | Method | Default | |--------|---------| | `has_view_permission(request, obj=None)` | Django's `view` permission | | `has_add_permission(request, obj=None)` | Django's `add` permission | | `has_change_permission(request, obj=None)` | Django's `change` permission | | `has_delete_permission(request, obj=None)` | Django's `delete` permission | An editor needs the matching permission on the model; superusers have them all. Grant permissions as you would anywhere in Django, through groups or user permissions. ## Object-level permissions The methods also receive the object, so a studio can decide per object. The answer guards every endpoint that works on one object: opening, saving, deleting, restoring and moving it, and running its buttons. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): def has_view_permission(self, request, obj=None): if obj is None: return super().has_view_permission(request) # Editors only see their own articles. return obj.author_id == request.user.id or request.user.is_superuser ``` Without an override, the object-level check falls back to the model-level check, as in the classic Django admin. The same methods also guard what surrounds an object: a related model's options only appear when the editor may view that model, and inline objects can only be added to parents the editor can see. ## Narrowing the objects an editor sees Lists are not filtered object by object. To hide objects from an editor altogether, override `get_queryset(request)`: the list, the trash, exports, bulk actions, the site search, relation pickers, saves of relations and the single-object endpoints all start from it, so an object outside the queryset is not listed and not found. Tenant scoping and the trash filter are applied on top of it. ```python @register(Article) class ArticleStudio(ModelStudio): def get_queryset(self, request): queryset = super().get_queryset(request) if request.user.is_superuser: return queryset # Editors only see their own articles. return queryset.filter(author=request.user) ``` An object that already refers to a hidden object stays editable: a relation the form sends back unchanged is kept, only a new choice must come from the related studio's queryset. Relations inside blocks are validated within the tenant only. Pair it with `has_view_permission(request, obj)` when an editor may see an object but not open it. ## Login throttling Login attempts are limited per IP address, to 10 per minute by default. Change or disable the limit with the [`LOGIN_THROTTLE_RATE`](https://djangocontentstudio.org/docs/settings.md#login_throttle_rate) setting: ```python # mysite/settings.py CONTENT_STUDIO = { "LOGIN_THROTTLE_RATE": "5/min", # None disables the limit } ``` > **Note** > > The limit is counted in the cache. With the default per-process cache, every > worker counts separately; configure a shared cache such as Redis or > Memcached when you run more than one worker. ## Field-level permissions Content Studio has no per-field permissions: an editor who may change an object sees all fields the studio shows. To hide a field from every editor, leave it out of the edit page with `exclude` or `edit_main`. Fields in `exclude` are also never sent to the admin, so `exclude` keeps a value such as an API key out of editors' browsers; a field you only leave out of `edit_main` is still sent. To let some editors see a field without changing it, return it from the studio's [`get_readonly_fields`](https://djangocontentstudio.org/docs/edit-page.md#read-only-fields) for those editors. To protect sensitive data from some editors only, keep it in a separate model with its own permissions. Source: https://djangocontentstudio.org/docs/permissions --- # Authentication Editors log in to the admin with their username and password, like in the classic Django admin. Content Studio handles the sessions itself and is configured entirely through the `CONTENT_STUDIO` setting, so it never interferes with the authentication your project uses elsewhere. ## Who can log in Only active staff users can log in, and every request checks this again: deactivating a user or revoking their staff status ends their access immediately. What an editor may see and do is decided by the [permission hooks](https://djangocontentstudio.org/docs/permissions.md). ## Sessions - **Short-lived.** A session stays active for an hour at a time ([`TOKEN_LIFETIME`](https://djangocontentstudio.org/docs/settings.md#token_lifetime)) and renews itself while the editor works. After a week without using the admin, the editor logs in again ([`REFRESH_TOKEN_LIFETIME`](https://djangocontentstudio.org/docs/settings.md#refresh_token_lifetime)). However active, a session ends 30 days after logging in ([`MAX_SESSION_LIFETIME`](https://djangocontentstudio.org/docs/settings.md#max_session_lifetime)). - **Survives a reload.** Reloading the page keeps the editor logged in. - **Ends everywhere on a password change.** Changing or resetting a password logs the user out on every device. - **Logging out** ends the session in that browser. The session cannot be continued afterwards, not even with a copy of its cookie. - **A copied session ends.** Each renewal replaces the session cookie and the old one stops working. When an old cookie is presented again, it was copied, and Content Studio ends the session for both. Sessions are signed with your `SECRET_KEY`, or with [`TOKEN_SIGNING_KEY`](https://djangocontentstudio.org/docs/settings.md#token_signing_key) when you set one. When you rotate `SECRET_KEY`, keep the old key in `SECRET_KEY_FALLBACKS` for a while, as Django documents: sessions signed with it then continue until they expire. ## Login backends Editors log in with their username and password, like in the classic Django admin. Login attempts are limited per IP address ([`LOGIN_THROTTLE_RATE`](https://djangocontentstudio.org/docs/settings.md#login_throttle_rate)). > **Note** > > Username and password is the only supported way to log in. Custom login > backends, such as single sign-on, are not supported. ## Password reset The `content_studio.contrib.password_reset` app lets editors reset a forgotten password with a code sent by email. 1. Add the app to `INSTALLED_APPS`: ```python # mysite/settings.py INSTALLED_APPS = [ # ... "content_studio", "content_studio.contrib.password_reset", ] ``` 2. Include its URLs under the admin's prefix: ```python # mysite/urls.py from django.urls import include, path urlpatterns = [ path("admin/", include("content_studio.contrib.password_reset.urls")), path("admin/", include("content_studio.urls")), ] ``` 3. Configure Django's `EMAIL_*` settings, so the code emails can be sent. An editor requests a code for their email address, enters it, and sets a new password. How the flow protects itself: - Only active staff users can reset their password. The email address matches regardless of case; an address shared by several users gets no code. - Codes have six digits and are valid for 10 minutes ([`PASSWORD_RESET_EXPIRATION_TIME`](https://djangocontentstudio.org/docs/settings.md#password_reset_expiration_time)). - Five wrong codes lock the email address out for an hour, also when new codes are requested in between. - Requests are limited per IP address ([`PASSWORD_RESET_THROTTLE_RATE`](https://djangocontentstudio.org/docs/settings.md#password_reset_throttle_rate)). - The new password must pass your project's `AUTH_PASSWORD_VALIDATORS`. - Unknown addresses and wrong codes get the same answer, so the flow does not reveal which addresses have an account. The emails are sent as a [Django task](https://docs.djangoproject.com/en/stable/topics/tasks/). Without a task backend, Django runs the task during the request. Configure a backend that runs tasks in a worker (the `TASKS` setting) and the request only queues the email: it then takes as long for an unknown address as for a known one, so its duration does not reveal which addresses exist either. On Django 5.2, which has no tasks framework, the [`django-tasks`](https://pypi.org/project/django-tasks/) package is used when installed; otherwise the email is sent during the request. `manage.py check --deploy` warns (`content_studio.W018`) while the emails are sent during the request. Source: https://djangocontentstudio.org/docs/authentication --- # Multi-tenancy With multi-tenancy, one admin serves several tenants, such as the organizations of a platform. Editors pick a tenant and only see and change that tenant's content. ## Set it up 1. Point the [`TENANT_MODEL`](https://djangocontentstudio.org/docs/settings.md#tenant_model) setting at your tenant model: ```python # mysite/settings.py CONTENT_STUDIO = { "TENANT_MODEL": "organizations.models.Organization", } ``` 2. Give each model that belongs to a tenant a foreign key to the tenant model. Content Studio finds it on its own: the one foreign key that points at the tenant model. Models without one are shared by all tenants. ## How content is scoped Editors choose a tenant in the admin, and everything they do happens within it: - Lists and searches only show the selected tenant's objects. - New objects are assigned to the selected tenant, and an object's tenant cannot be changed afterwards. - Relation fields only offer objects of the selected tenant. - The dashboard's recent activity only shows changes to the selected tenant's objects that still exist. - Without a selected tenant, tenant models show no objects at all. - A tenant the editor may not access is refused. ## Decide who works in which tenant The site's `get_tenants` method decides which tenants an editor may work in. It feeds the tenant selector and guards every page, so this one method is all the authorization multi-tenancy needs. By default it returns all tenants: ```python # mysite/studio.py from content_studio.admin import ContentStudioSite class BlogStudioSite(ContentStudioSite): def get_tenants(self, tenant_model, **kwargs): user = kwargs["request"].user return tenant_model.objects.filter(members=user) admin_site = BlogStudioSite() ``` Point the [`ADMIN_SITE`](https://djangocontentstudio.org/docs/settings.md#admin_site) setting at the site to use it. ## What every tenant shares Two parts of the admin are not scoped to a tenant, whichever tenant the editor works in: - The [media library](https://djangocontentstudio.org/docs/media-library.md): every editor sees the same folders and files, also when the media model has a foreign key to the tenant model. - [Webhooks](https://djangocontentstudio.org/docs/webhooks.md): editors see and change every webhook and its delivery log. A webhook's **Tenant** field chooses whose changes trigger it, not who may manage it. Give the permissions on these models only to editors who may work across tenants. Source: https://djangocontentstudio.org/docs/multi-tenancy --- # Webhooks Webhooks send a request to a URL after editors change content in the admin. Use them to rebuild a static site, clear a cache or notify another service, for example with the build hook of your hosting provider. In Python, the [`content_changed` signal](#react-in-python) gives you the same changes without a webhook. ## Set up a webhook 1. Add the webhooks app to your project: ```python # mysite/settings.py INSTALLED_APPS = [ # ... "content_studio", "content_studio.webhooks", ] ``` 2. Run `manage.py migrate` to create its tables. 3. Schedule the delivery command, which retries failed requests and removes old ones. For example, every minute from cron: ```text * * * * * /srv/mysite/venv/bin/python /srv/mysite/manage.py content_studio_deliver_webhooks ``` 4. In the admin, open **Webhooks** in the main menu and create a webhook with a name and the URL to call, such as your hosting provider's build hook. 5. Copy the secret the admin shows after you create the webhook. It is shown only this once; you need it to [verify the signature](#verify-the-signature). From now on, every change an editor makes in the admin sends a `POST` request to the URL. ## What triggers a webhook These changes in the admin trigger webhooks: - creating, editing and deleting objects, including bulk deletes; - moving objects to the trash and restoring them; - reordering objects; - uploading, editing, cropping, moving and deleting media items, and creating, renaming, moving and deleting media folders. Deleting a folder also counts for the subfolders and media items deleted with it. Changes are sent after they are saved; a change that fails or is rolled back sends nothing. Everything one action changes goes into one request per webhook, so a bulk delete of fifty articles starts one build, not fifty. Changes made outside the admin, such as in `manage.py shell`, a data import or your own views, do not trigger webhooks. Your own [bulk actions](https://djangocontentstudio.org/docs/list-page.md#bulk-actions) only do when they record their changes with `record_change`. Changes to the webhooks themselves never trigger a webhook. ## Choose what triggers it By default a webhook fires on every change. Three fields on the webhook narrow it down; an empty field matches everything: - **Events**: created, updated, deleted, restored and reordered. - **Models**: the models whose changes count. You choose from the models registered in the admin. - **Tenant**: with [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), the primary key of the tenant whose changes count. Changes to models without a tenant then do not count. Turn off **Active** to pause a webhook without deleting it. ## Work with webhooks in the admin A webhook's edit page has two buttons in its header: - **Trigger now** sends a request without changes right away, with the event `webhook.triggered`. Use it to start a build by hand, or to check that the URL works: the admin tells you how the webhook responded. - **Regenerate secret** replaces the secret and shows the new one once. Requests are signed with the new secret from then on, so update every receiver that checks the signature. Under the fields, the delivery log lists the webhook's requests, newest first, with their status, attempts, response and error. Open a delivery to see what it sent and received. A failed delivery has a **Retry** button that sends it once more, right away. The webhook models follow Django's permissions, like any registered model. Viewing a webhook shows its delivery log; the buttons need change permission. Choosing models also needs view permission on content types (`contenttypes.view_contenttype`), which superusers have. To create webhooks in code instead, such as in a data migration, use the models directly: ```python from django.contrib.contenttypes.models import ContentType from blog.models import Article, Category from content_studio.webhooks.models import Webhook webhook = Webhook.objects.create( name="Rebuild the blog", url="https://api.netlify.com/build_hooks/5f3a9c0e2b1d4e0012345678", events=["created", "updated", "deleted", "restored"], ) webhook.content_types.set( ContentType.objects.get_for_models(Article, Category).values() ) ``` ## A deploy button and a dashboard widget Two optional additions put webhooks within reach of every editor. Neither appears until you add it to your site. `DeployButton` is a button in the main menu that triggers the active webhooks, after confirming, with the same request as **Trigger now**. Pass `webhooks` to trigger only the webhooks with those names; with [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), it triggers the current tenant's webhooks and those without a tenant. It is shown to editors who may change webhooks, and takes the options of a [`MainMenuButton`](https://djangocontentstudio.org/docs/extensions.md#mainmenubutton), such as its `label`. `WebhookDeliveriesWidget` shows every webhook on the dashboard with its latest delivery: when it was made, whether it was delivered, the error of a failed one and when a retry is due. Editors who may not view webhooks see an empty list. ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.dashboard import Dashboard from content_studio.webhooks.dashboard import WebhookDeliveriesWidget from content_studio.webhooks.extensions import DeployButton class BlogStudioSite(ContentStudioSite): extensions = [DeployButton(webhooks=["Rebuild the blog"])] dashboard = Dashboard(widgets=[WebhookDeliveriesWidget()]) admin_site = BlogStudioSite() ``` ## The request Each request is a `POST` with a JSON body: ```json { "version": 1, "event": "content.changed", "delivery": "0b8e2c55-6f4e-4f6a-9d1c-3c2f5a7e9b10", "webhook": "Rebuild the blog", "timestamp": "2026-10-27T09:30:12.481930+00:00", "changes": [ { "action": "updated", "model": "blog.article", "id": "42", "label": "Ten tips for better headlines", "tenant": null, "timestamp": "2026-10-27T09:30:12.402117+00:00" } ] } ``` - `version`: the format of the body; it only changes in a major release. - `event`: `content.changed` for changes in the admin, `webhook.triggered` for a request sent with **Trigger now** or the deploy button, which has no changes. - `delivery`: the request's ID. A retry sends the same ID, so the receiver can skip a request it has already handled. - `changes`: the changes the webhook matches, each with its action, the model as `app_label.model_name`, the object's primary key as a string, its name, its tenant and when it was made. The body does not contain the content itself. To fetch the changed objects, the receiver reads them from your own API, for example one built with [django-headless](https://github.com/BitsOfAbstraction/django-headless). These headers come with it: | Header | Value | |--------|-------| | `X-Content-Studio-Event` | The event, as in the body. | | `X-Content-Studio-Delivery` | The request's ID, as in the body. | | `X-Content-Studio-Signature` | `t=,v1=`: see below. | ## Verify the signature Every webhook has a secret, generated when you create it and shown once then, and again when you regenerate it. The signature is the HMAC-SHA256 of the timestamp, a period and the raw body, with the secret as the key. Check it before you trust a request, and refuse old timestamps so a recorded request cannot be replayed: ```python # receiver.py import hashlib import hmac import time def is_valid(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: fields = dict(part.split("=", 1) for part in header.split(",")) timestamp = int(fields["t"]) expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256 ).hexdigest() return abs(time.time() - timestamp) <= tolerance and hmac.compare_digest( expected, fields["v1"] ) ``` A build hook URL that contains its own token, like the one above, does not need to check the signature. ## Delivery and retries The webhook's delivery log shows every request, with the response status, the start of the response body and any error. Requests are sent at the end of the editor's request. To send them from a worker instead, configure a [Django task](https://docs.djangoproject.com/en/stable/topics/tasks/) backend, such as [Huey](https://djangocontentstudio.org/docs/dashboard.md#huey); on Django 5.2, this needs the `django-tasks` package. A response with a 2xx status code counts as delivered. Anything else, such as a timeout or a 503, is retried by `content_studio_deliver_webhooks`, after 1, 4, 16 and 64 minutes. After [`WEBHOOK_MAX_ATTEMPTS`](https://djangocontentstudio.org/docs/settings.md#webhook_max_attempts) attempts, the delivery is marked as failed and a warning is logged to the `content_studio.webhooks` logger. With the [notifications app](https://djangocontentstudio.org/docs/notifications.md#built-in-events) installed, editors who may change the webhook are also notified. **Retry** in the delivery log makes one more attempt. The same command removes finished deliveries after [`WEBHOOK_DELIVERY_RETENTION`](https://djangocontentstudio.org/docs/settings.md#webhook_delivery_retention) days. > **Caution** > > Without the scheduled command, a delivery that fails once is never retried, > and with a task backend that only stores tasks, nothing is sent at all. > Schedule the command in every deployment that uses webhooks. ## Security Webhooks only connect to public IP addresses, over HTTPS, and do not follow redirects. A URL that resolves to a private, loopback or link-local address fails, so a webhook cannot reach services on your own network. To call such a host on purpose, such as a build server on your network, add it to [`WEBHOOK_ALLOWED_HOSTS`](https://djangocontentstudio.org/docs/settings.md#webhook_allowed_hosts). Plain HTTP works for those hosts, and for every host while `DEBUG` is on. Anyone who can change a webhook can send your content changes to any URL. Give the permissions on the webhook models only to people you trust with that, like the permissions on users. See [Access control](https://djangocontentstudio.org/docs/permissions.md). With [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), webhooks are shared by every tenant: an editor with these permissions manages the webhooks of all tenants. ## React in Python To act on changes in your own code, connect a receiver to the `content_changed` signal. It is sent after the changes of a request in the admin are saved, with all of them in `changes`: ```python # blog/signals.py from django.dispatch import receiver from content_studio.signals import content_changed @receiver(content_changed) def log_changes(sender, changes, user, **kwargs): for change in changes: print(change.action, change.model.__name__, change.object_id) ``` Import the module in your app's `ready()` method so the receiver is connected. Each change is a `Change` with `action`, `model`, `object_id`, `object_repr`, `tenant_id`, `user` and `timestamp`. The signal does not need the webhooks app; an error in a receiver is logged and does not affect the editor's request. Related pages: [Settings reference](https://djangocontentstudio.org/docs/settings.md#webhooks), [System checks](https://djangocontentstudio.org/docs/deployment.md#system-checks). Source: https://djangocontentstudio.org/docs/webhooks --- # Notifications Notifications tell editors in the admin what other editors changed. An editor hears about changes to the objects they created, to every object of a model they subscribed to, and to the objects they follow. Your project can add events of its own, such as an article submitted for review, and send them from Python. Notifications are an optional app. They live in the admin, and an optional email digest sends editors the ones they leave unread. ## Set up notifications 1. Add the notifications app to your project: ```python # mysite/settings.py INSTALLED_APPS = [ # ... "content_studio", "content_studio.notifications", ] ``` 2. Run `manage.py migrate` to create its tables. 3. Schedule the prune command, which removes old notifications and the follows of objects that no longer exist. For example, once a night from cron: ```text 0 3 * * * /srv/mysite/venv/bin/python /srv/mysite/manage.py content_studio_prune_notifications ``` The main menu then shows **Notifications** under the dashboard, and every editor gets a notification settings page. ## What editors see - **The main menu item** shows the number of unread notifications. It opens the list, with an **Unread** and an **All** tab. On narrow screens it opens the list as a page. - **The notifications page**, reached with **See all** under the list, shows every notification with the same tabs, and filters them by model and by event. Its search finds notifications by the object's name, the text, the objects a summary names and who made the change; every word must match. The filters are kept in the page's address, so a filtered page can be bookmarked or shared. - **The list** names who did what to which object, and links to the object while it exists; deleted objects are named but not linked. Opening a notification marks it as read. The dot beside an unread one marks it as read without opening it, and **Mark all as read** marks every unread one the editor has seen. Notifications that arrive while the list is open wait behind a button at the top, so the rows do not move under the pointer. - **Toasts** announce new notifications in the corner of the admin. They never show the notifications that were there when the admin loaded, show at most three at a time and combine more into one. An editor turns them off on the settings page; the choice applies to that browser only. - **Follow**, in the edit page's header, notifies the editor about every change to that object. On narrow screens it is in the more actions menu. The admin asks for new notifications every 30 seconds while the browser tab is visible, and pauses while it is in the background. With several tabs of the admin open in one browser, one visible tab asks and shares the answer with the others, and marking a notification as read in one tab marks it in all of them. ## Email digest The digest emails each editor the notifications they left unread, in one email with links to the admin. A notification is mailed once, after it went unchanged and unread for an hour, so an editor working in the admin reads it there first. Editors turn the email off on their notification settings page; editors without an email address get none. 1. Set up [Django's email](https://docs.djangoproject.com/en/stable/topics/email/) for your project. 2. Turn the digest on, and tell Content Studio where the admin is, for the links: ```python # mysite/settings.py CONTENT_STUDIO = { # ... "NOTIFICATION_EMAIL_DIGEST": True, "ADMIN_URL": "https://example.com/admin/", } ``` 3. Schedule the digest command, for example every hour from cron: ```text 0 * * * * /srv/mysite/venv/bin/python /srv/mysite/manage.py content_studio_send_notification_digest ``` `--delay` sets how many minutes a notification stays unread and unchanged before it is mailed, 60 by default. The emails are written in your project's `LANGUAGE_CODE`, and show what the editor may see in the admin, with the same permission checks as the list. To change how the email looks, override the templates `content_studio/notifications/digest.txt` and `digest.html` in your project's templates. They get `user`, `name` (the editor's name), `site_name`, `count`, `items` (each with `text`, `detail` and `url`, which is empty for deleted objects), `rest` (how many more than the 20 shown), `admin_url`, `notifications_url` and `settings_url`. ## A dashboard widget `NotificationsWidget` shows the editor's latest notifications on the dashboard, unread ones marked, with the number of unread ones and a link to all of them. It updates when a new notification arrives. `limit` sets how many it shows, 5 by default and at most 20; `title` and `description` replace its texts. ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from content_studio.dashboard import Dashboard from content_studio.notifications.dashboard import NotificationsWidget class BlogStudioSite(ContentStudioSite): dashboard = Dashboard(widgets=[NotificationsWidget(limit=8)]) admin_site = BlogStudioSite() ``` System check `content_studio.W021` reports the widget on a site without the notifications app. ## What notifies whom Without a choice of their own, editors are notified when another editor updates, deletes or restores an object they created. Who created an object comes from the classic Django admin's activity log, so this works for every model created in the admin, with or without a creator field. On their notification settings page, editors choose per model: - **Which ones**: every object of the model, or the objects they created. - **Which changes**: created, updated, deleted and restored. Creating an object never notifies its creator, so with "Created by me" there is no created column. A singleton only has updated. Every change on the page is saved as it is made. Followed objects notify the editor about every change, whatever the settings say, and are listed on the same page with a button to stop following them. ## Change the defaults Override `get_default_subscriptions(user)` on your site to change what an editor gets before they choose, for example per group. It returns a dict: a model maps to the changes and whose objects they are about, a registered event's key to `True` or `False`. ```python # mysite/studio.py from content_studio.admin import ContentStudioSite from blog.models import Article class BlogStudioSite(ContentStudioSite): def get_default_subscriptions(self, user): if any(group.name == "Editors" for group in user.groups.all()): return { Article: {"events": ["created", "updated"], "scope": "all"}, "blog.article_submitted": True, } return {} admin_site = BlogStudioSite() ``` - `events`: any of `"created"`, `"updated"`, `"deleted"` and `"restored"`. - `scope`: `"all"` for every object of the model, or `"mine"` (the default) for the objects the editor created. Models and events you leave out keep the defaults above. An editor's own choice always wins, and the settings page shows the defaults until they make one, so changing the method changes what every editor without a choice of their own gets. Required events stay on. The method is called for every active staff user when a change notifies anyone, with the user's groups loaded: keep it to what it can read from the user, as in the example, without queries of its own per user. ## Rules that always hold Some rules hold whatever editors choose: - Nobody is notified about their own changes. - Editors only see notifications about objects they may view (see [Access control](https://djangocontentstudio.org/docs/permissions.md)). - Repeated changes do not flood the list: a bulk action is one notification, and further changes to an object update its unread notification. - With [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), notifications and settings are kept per tenant. ## Leave a model out Set `notifications = False` on a studio to leave its model out: its changes notify nobody, it is not on the settings page and its edit page has no Follow button. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Review @register(Review) class ReviewStudio(ModelStudio): notifications = False ``` ## Add events of your own Register an event with `register_event`, in your app's `ready()`, so it appears on the settings page. Then send it with `notify`. ```python # blog/apps.py from django.apps import AppConfig from django.utils.translation import gettext_lazy as _ class BlogConfig(AppConfig): name = "blog" def ready(self): from content_studio.notifications import register_event register_event( "blog.article_submitted", _("Article submitted for review"), description=_("An author asks for a review before publishing."), ) ``` ```python # blog/views.py from django.contrib.auth.models import Group from django.utils.translation import gettext_lazy as _ from content_studio.notifications import notify def submit_for_review(request, article): notify( "blog.article_submitted", title=_("%(title)s is ready for review"), params={"title": article.title}, target=article, actor=request.user, recipients=Group.objects.filter(name="Editors"), ) ``` `register_event` takes: - `key`: lowercase dotted words, like `"blog.article_submitted"`. Keys that start with `content.` are taken by content changes. - `label` and `description`: shown on the settings page. - `level`: `"info"` (the default), `"success"`, `"warning"` or `"error"`, which sets the icon in the list and the toast. - `default`: whether editors get the event until they turn it off (the default, `True`) or only after they turn it on. - `required`: delivered to everyone, without a switch to turn it off. The settings page shows it as required. `notify` takes: - `title` and `body`: the text, translated when the notification is read, in the reader's language, and filled in with `params`. Use `gettext_lazy`, as in the example. - `target`: the object it is about. The notification then links to its edit page, and only editors who may view the object are notified. Without a target, `url` sets where it leads: a path in the admin or a full URL. - `recipients`: users, groups, or querysets of either. Without recipients, the event goes to the editors who have it on: everyone who did not turn it off for a default event, everyone for a required one. - `actor`: who did it. The actor is never notified. - `key`: an unread notification of the same event with the same key is bumped instead of repeated, like "3 comments await moderation". - `level`: overrides the event's level. Only active staff users are notified. Called inside a transaction, `notify` writes the notifications once the transaction commits, and not at all when it rolls back. ## Built-in events With the [webhooks app](https://djangocontentstudio.org/docs/webhooks.md) installed, **Webhook delivery failed** notifies the editors who may change a webhook when one of its deliveries fails after the last attempt. It is on by default, and editors turn it off on the settings page. A webhook that keeps failing bumps its unread notification rather than adding one per delivery. ## Retention `content_studio_prune_notifications` removes read notifications older than [`NOTIFICATION_RETENTION`](https://djangocontentstudio.org/docs/settings.md#notification_retention) days, 90 by default, and unread ones older than [`NOTIFICATION_UNREAD_RETENTION`](https://djangocontentstudio.org/docs/settings.md#notification_unread_retention) days, which keeps them by default. The list's footer tells editors how long read notifications are kept. Source: https://djangocontentstudio.org/docs/notifications --- # Presence Presence shows editors who else has an object open. On the edit page, the avatars of the other editors appear in the header; on the list page, a row shows the avatars of the editors who have that object open. It helps editors notice that a colleague is working on the same object before their changes cross. ## What editors see - **On the edit page**, the header shows an avatar for each other editor who has the object open. The tooltip names the editor and says whether they are viewing the object or editing it. An editor counts as editing once their form has unsaved changes; their avatar then gets a ring in the color of unsaved changes. - **On the list page**, the table view and the list view mark the rows of objects another editor has open, with the same avatars and tooltip. Editors do not see themselves. An editor with the same object open in two browser tabs appears once, as editing when either tab has unsaved changes. The admin refreshes presence about every 15 seconds while the page is visible, and pauses while the browser tab is in the background. Leaving the edit page removes the editor right away; an editor whose browser closes without leaving the page disappears within a minute. Presence is informational. It does not lock objects and does not stop anyone from saving: like the classic Django admin, the last save wins. ## Who sees what Presence follows the same rules as the objects themselves: - Editors only see presence for objects they may view: the studio's `has_view_permission` decides, per object, as on the list page. See [Access control](https://djangocontentstudio.org/docs/permissions.md). - With [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), presence is kept per tenant for tenant-scoped models. - Objects in the trash have no presence. - Inlines are part of their parent's edit page and have no presence of their own. The avatars show the editor's full name, or the username when the name is empty. The picture or initials come from the user model's relation display: override `get_relation_display` on the user model's studio to set them. ```python # blog/admin.py from django.contrib.auth import get_user_model from content_studio import register from content_studio.admin import ModelStudio, RelationDisplay @register(get_user_model()) class UserStudio(ModelStudio): table_display = ["username", "first_name", "last_name"] def get_relation_display(self, obj, request): return RelationDisplay( title=obj.get_full_name() or obj.get_username(), initials=f"{obj.first_name[:1]}{obj.last_name[:1]}", ) ``` ## Configure a shared cache Presence is kept in Django's cache. With the default per-process cache (`LocMemCache`), every worker process keeps its own presence, and editors only see the colleagues who happen to be served by the same process. That works with the development server, which runs a single process, but not in a deployment with several workers or servers. Configure a shared cache, such as Redis, Memcached or the [database cache](https://docs.djangoproject.com/en/stable/topics/cache/#database-caching): ```python # mysite/settings.py CACHES = { "default": { "BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": "redis://127.0.0.1:6379", } } ``` `manage.py check --deploy` reports presence in a per-process cache as `content_studio.W017`. To keep presence apart from your other cached data, point [`PRESENCE_CACHE`](https://djangocontentstudio.org/docs/settings.md#presence_cache) at another cache in `CACHES`. ## Turn presence off Presence is on for every registered model. Set `presence = False` on a studio to turn it off for that model: ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Category @register(Category) class CategoryStudio(ModelStudio): presence = False ``` To turn it off everywhere, set [`PRESENCE`](https://djangocontentstudio.org/docs/settings.md#presence) to `False`: ```python # mysite/settings.py CONTENT_STUDIO = { "PRESENCE": False, } ``` ## Related pages - [Settings reference](https://djangocontentstudio.org/docs/settings.md#presence) - [System checks](https://djangocontentstudio.org/docs/deployment.md#system-checks) - [Access control](https://djangocontentstudio.org/docs/permissions.md) Source: https://djangocontentstudio.org/docs/presence --- # Command palette The command palette is the quickest way around the admin. Press **⌘K** on macOS or **Ctrl K** on Windows and Linux, or click the search button at the top of the main menu, and type where you want to go. ## What it offers - **Navigate** to the dashboard, the media library and every registered model the editor may view. - **Create** a new object of every model the editor may add. - **Trash**: the trash page of every model with [soft delete](https://djangocontentstudio.org/docs/list-page.md#soft-delete-and-the-trash). - **Theme**: light, dark or the system setting. - **Log out**. The entries filter as you type. ## Search After two or more characters, the palette also searches the objects themselves: every registered model with `search_fields`, matched the same way as on its list page. Results appear grouped by model; picking one opens its edit page. ```python # blog/admin.py from content_studio import register from content_studio.admin import ModelStudio from .models import Article @register(Article) class ArticleStudio(ModelStudio): search_fields = ["title"] # also searched from the palette ``` The search only covers models the editor may view, only the selected tenant's objects, and leaves out objects in the trash. It shows at most five results per model, so one large model cannot crowd out the others; the list page's own search finds everything. Source: https://djangocontentstudio.org/docs/command-palette --- # Deployment Everyone who can log in to the admin can change your content. This page lists what to configure before you go live, what Content Studio protects out of the box, and the system checks that catch configuration mistakes. ## Deployment checklist - **Serve the admin over HTTPS.** The session cookie is marked `Secure` whenever `DEBUG` is off ([`REFRESH_COOKIE_SECURE`](https://djangocontentstudio.org/docs/settings.md#refresh_cookie_secure)). Behind a proxy that terminates TLS, also set Django's `SECURE_PROXY_SSL_HEADER`. - **Configure a shared cache**, such as Redis, Memcached or the database cache. The login and password reset limits are counted in the cache; with the default per-process cache, every worker counts separately. Ended sessions are recorded in the cache ([`TOKEN_CACHE`](https://djangocontentstudio.org/docs/settings.md#token_cache)); with a per-process cache, a session logged out in one worker continues in another. [Presence](https://djangocontentstudio.org/docs/presence.md) is kept in the cache too. - **Pass on the client's IP address.** The limits count per IP address. Without further configuration, Content Studio uses the address of the connection and ignores `X-Forwarded-For`, which any client can set. Behind a proxy, every request then comes from the proxy's address and all clients share one limit. Set `NUM_PROXIES` in the [Django REST Framework settings](https://www.django-rest-framework.org/api-guide/settings/#num_proxies) to the number of proxies in front of your application, and make sure those proxies add the client's address to `X-Forwarded-For`. - **Serve uploads from a separate domain** when you can, such as a CDN or an object storage bucket. Even with the upload checks, a file served from your own domain runs with your site's privileges if a browser renders it. - **Clear unsaved uploads daily.** Files picked in a [`FileWidget`](https://djangocontentstudio.org/docs/widgets.md#files) but never saved stay in the upload folder until `manage.py content_studio_clear_uploads` removes them. Run it once a day, for example with cron. - **Configure a task backend** that runs Django tasks in a worker. Password reset emails are then sent outside the request, so its duration does not reveal whether an address has an account ([password reset](https://djangocontentstudio.org/docs/authentication.md#password-reset)). - **Keep `DEBUG` off** in production, and keep Django, Content Studio and your other dependencies up to date. - **Use Django's security middleware and settings:** `SecurityMiddleware` with HSTS, `XFrameOptionsMiddleware`, and `SESSION_COOKIE_SECURE` and `CSRF_COOKIE_SECURE` for the rest of your site. `manage.py check --deploy` lists what is missing. - **Keep `SECRET_KEY` strong and secret**, or set a separate [`TOKEN_SIGNING_KEY`](https://djangocontentstudio.org/docs/settings.md#token_signing_key): it signs the sessions. ## What Content Studio protects - **Staff only.** Like the classic Django admin, only active staff users can log in, and every request checks this again. Deactivating a user or revoking their staff status ends their access immediately. What an editor may see and do is decided by the [permission hooks](https://djangocontentstudio.org/docs/permissions.md). - **Short sessions.** Sessions renew themselves while an editor works and end after a week without use, and 30 days after logging in at the latest ([`MAX_SESSION_LIFETIME`](https://djangocontentstudio.org/docs/settings.md#max_session_lifetime)). Changing or resetting a password logs the user out everywhere ([authentication](https://djangocontentstudio.org/docs/authentication.md)). - **Logging out ends the session on the server.** A copy of the session cookie stops working at logout, and each renewal invalidates the previous cookie. A cookie presented again after its renewal was copied, so Content Studio ends that session. - **Limited login and password reset attempts**, per IP address ([`LOGIN_THROTTLE_RATE`](https://djangocontentstudio.org/docs/settings.md#login_throttle_rate), [`PASSWORD_RESET_THROTTLE_RATE`](https://djangocontentstudio.org/docs/settings.md#password_reset_throttle_rate)). The address is the one the connection comes from; an `X-Forwarded-For` header sent by the client is ignored unless you configure your proxies (see the checklist below). Five wrong reset codes lock an email address out, and new passwords must pass `AUTH_PASSWORD_VALIDATORS`. - **Sanitized rich text.** `HTMLField` cleans its value on save with an allowlist sanitizer ([`HTML_SANITIZER`](https://djangocontentstudio.org/docs/settings.md#html_sanitizer)). - **Checked uploads.** The media library accepts a list of file types up to a maximum size, and rejects markup such as HTML and SVG ([`MEDIA_LIBRARY_ALLOWED_EXTENSIONS`](https://djangocontentstudio.org/docs/settings.md#media_library_allowed_extensions)). File fields reject markup too, and a file is only attached to an object by the editor who uploaded it. - **Write-only secrets.** A field with [`PasswordWidget(write_only=True)`](https://djangocontentstudio.org/docs/widgets.md#secrets) never sends its value to the browser or to an export. - **Nothing public but the login pages.** Before logging in, the admin only receives what it needs to show the login form. ## System checks Content Studio checks its configuration with Django's system check framework: on `manage.py check`, when the development server starts, on `makemigrations` and in your CI. An error means the configuration is broken and would fail at runtime, often silently. A warning means it works, but probably not as you intended. Neither stops the server from starting, but `manage.py check` exits with an error code. ### Errors | ID | Reports | |----|---------| | `content_studio.E001` | `list_display` set to a mapping. The list view's mapping belongs in `list_item_display`; `list_display` sets the table columns. | | `content_studio.E002` | `list_views` with an unknown view name. | | `content_studio.E003` | Two extensions with the same ID. Pass an `extension_id` to tell them apart. | | `content_studio.E004` | Two dashboard widgets with the same ID, on the start page and the [dashboard pages](https://djangocontentstudio.org/docs/extensions.md#dashboardpage) together. Pass a `widget_id` to tell them apart. | | `content_studio.E005` | Two components with the same ID on one studio, or on an inline and the inlined model's own studio. Pass a `component_id` to tell them apart; the same component class may serve several studios. | | `content_studio.E006` | `table_display` entries that are not field names or `TableColumn` objects, duplicate columns, columns that are not a model field, a model property, `__str__` or a studio method, and formats that are not `content_studio.formats` classes or instances. Inlines are checked too. | | `content_studio.E007` | Bulk actions: an `actions` list with unknown or duplicate names, a `@bulk_action` that replaces the built-in delete or export, `"export"` in `actions` without export, or an unknown permission. | | `content_studio.E008` | `soft_delete` naming an unknown field, or a field that is not a nullable date or date and time. | | `content_studio.E009` | A `FOCAL_POINT_ATTR` setting, which is removed: the focal point is one of a media item's [properties](https://djangocontentstudio.org/docs/media-library.md#properties). | | `content_studio.E010` | Edit page fields that are not model fields or properties, which would show as empty read-only boxes, and `Field(widget=...)` values that are not `content_studio.widgets` classes or instances. | | `content_studio.E011` | `search_fields`, `ordering` and `list_filter` entries that are not a field, and `list_item_display` values that are not a field or a model method. Inlines are checked too. | | `content_studio.E012` | An inline's `fk_name` that is not a field, or not a foreign key (or one-to-one field) to the parent model; or no `fk_name` on an inline whose model has no, or more than one, foreign key to the parent. The inline is not served. | | `content_studio.E013` | Values in the site's `default_widget_mapping` or `default_format_mapping` that are not widget or format classes or instances. | | `content_studio.E014` | A `MEDIA_LIBRARY_ALLOWED_EXTENSIONS` that is not a list of extensions without the dot, or a `MEDIA_LIBRARY_MAX_UPLOAD_SIZE` that is not a positive number of bytes. | | `content_studio.E015` | `readonly_fields` entries that are not model fields or properties. Inlines are checked too. | | `content_studio.E016` | `prepopulated_fields` that is not a mapping, fields or source fields that are not model fields, fields that cannot be prepopulated (`DateTimeField`, `ForeignKey`, `OneToOneField`, `ManyToManyField`) and fields in `readonly_fields`. Inlines are checked too. | | `content_studio.E017` | A `reorder_field` that is not a model field or, unless the studio overrides `reorder`, not a number field, and `reorder_scope` entries that are not model fields. Inlines are checked too. | | `content_studio.E018` | A `BlockField` without blocks, with entries in `blocks` that are not `Block` subclasses, with two blocks of the same name, with a negative `min_num` or `max_num`, or with `min_num` greater than `max_num`. Also block fields that a block cannot store: `ManyToManyField`, `ManyMediaField`, `FileField` and auto fields, a `BlockField` in a block inside a block (blocks inside blocks go one level deep), and relations to models that are not installed. | | `content_studio.E019` | Webhook settings: a `WEBHOOK_ALLOWED_HOSTS` that is not a list of host names, a `WEBHOOK_MAX_ATTEMPTS` that is not a positive whole number, a `WEBHOOK_TIMEOUT` that is not a positive number of seconds, or a `WEBHOOK_DELIVERY_RETENTION` that is not `None` or a positive whole number of days. | | `content_studio.E020` | An `edit_header` that is not a list, or entries in it that are not components, such as fields, or are text components (`Title`, `Subtitle`, `Text`, `Callout`) or an `SEOPreview`. Inlines are checked too. | | `content_studio.E021` | A `duplicate_inlines` that is not a list, or entries in it that are not in the studio's `inlines`. | | `content_studio.E022` | An `export_fields` that is not a list, or entries in it that are not field names or `TableColumn` objects, that appear twice, or that do not resolve to a model field, a model property, `__str__` or a method on the studio. | | `content_studio.E023` | A [`PRESENCE_CACHE`](https://djangocontentstudio.org/docs/settings.md#presence_cache) that is not a cache in `CACHES`. | | `content_studio.E024` | An `edit_width` that is not `"default"` or `"wide"`. Inlines are checked too. | | `content_studio.E025` | A [`MEDIA_METADATA_ATTR`](https://djangocontentstudio.org/docs/settings.md#media_metadata_attr) you set without a `MEDIA_LIBRARY_MODEL`, or naming a field that is missing or not a `JSONField`. The default is not checked. | | `content_studio.E026` | A [`MEDIA_PROPERTIES_ATTR`](https://djangocontentstudio.org/docs/settings.md#media_properties_attr) you set without a `MEDIA_LIBRARY_MODEL`, or naming a field that is missing or not a `JSONField`. The default is not checked. | | `content_studio.E027` | A [`THUMBNAIL_CACHE`](https://djangocontentstudio.org/docs/settings.md#thumbnail_cache) that is not a cache in `CACHES`, while thumbnails are on. | | `content_studio.E028` | A [`DashboardPage`](https://djangocontentstudio.org/docs/extensions.md#dashboardpage) path that one of the admin's own pages uses, such as `content/` or `media-library/`. | | `content_studio.E029` | A dashboard widget's `refresh_interval` that is not a positive number of seconds or `None`. | | `content_studio.E030` | A [`NOTIFICATION_RETENTION`](https://djangocontentstudio.org/docs/settings.md#notification_retention) or [`NOTIFICATION_UNREAD_RETENTION`](https://djangocontentstudio.org/docs/settings.md#notification_unread_retention) that is not a positive whole number of days or `None`. | | `content_studio.E031` | A notification event registered with `register_event` whose key is not lowercase dotted words (like `blog.article_submitted`), starts with `content.`, has an unknown level, or is registered twice with different definitions. | | `content_studio.E032` | A [`NOTIFICATION_EMAIL_DIGEST`](https://djangocontentstudio.org/docs/settings.md#notification_email_digest) that is not `True` or `False`, an [`ADMIN_URL`](https://djangocontentstudio.org/docs/settings.md#admin_url) that is not an absolute `http` or `https` URL, or the digest turned on without `ADMIN_URL`. | | `content_studio.E033` | An [`IMAGE_SIZES`](https://djangocontentstudio.org/docs/settings.md#image_sizes) size with an invalid name or option, such as an unknown option, no width or height, or `"cover"` without both. The size is not made. | | `content_studio.E034` | A [`MAP_TILE_URL`](https://djangocontentstudio.org/docs/settings.md#map_tiles) without `{z}`, `{x}` and `{y}`, a `MAP_TILE_ATTRIBUTION` that is not a string, a [`MAP_GEOCODER_URL`](https://djangocontentstudio.org/docs/settings.md#map_geocoder_url) that is not `None` or an `http(s)` URL, or an [`OEMBED_PROVIDERS`](https://djangocontentstudio.org/docs/settings.md#oembed_providers) that is not `None` or a list of providers with a name, an `http(s)` endpoint and valid URL patterns. | | `content_studio.E035` | A [`MapWidget`](https://djangocontentstudio.org/docs/widgets.md#maps) on a field that is not a `JSONField`, or a `FloatField` or `DecimalField` with an `lng_field` naming another number field; or `lng_field` on a `JSONField`. | | `content_studio.E036` | An [`SEOPreview`](https://djangocontentstudio.org/docs/edit-page.md#seo-preview) whose `title_field`, `description_field` or `path_field` is not a model field. | | `content_studio.E037` | [Analytics](https://djangocontentstudio.org/docs/analytics.md) set up wrong: an `AnalyticsPage`, link, `Analytics` component or analytics widget without an `AnalyticsExtension`, more than one extension or page, a provider that reports metrics or breakdowns Content Studio does not know, or an analytics page path that another page uses. | | `content_studio.E038` | The same model inlined twice on one studio. An inline is served at one endpoint per parent and model, so the second inline is not served. | | `content_studio.E039` | With [`TENANT_MODEL`](https://djangocontentstudio.org/docs/settings.md#tenant_model) set, a registered or inlined model with more than one foreign key to the tenant model. Tenant scoping needs exactly one. | | `content_studio.E040` | A [condition](https://djangocontentstudio.org/docs/edit-page.md#conditional-fields) (`when`) that reads a field the model does not have, or an excluded or write-only field, whose value never reaches the admin. | ### Warnings | ID | Reports | |----|---------| | `content_studio.W001` | A required model field missing from `edit_main` and `edit_sidebar`, so saving fails. Fields with a default, nullable or blank fields, and fields Content Studio fills in itself do not count. | | `content_studio.W002` | A required model field set to `readonly=True` on the edit page or listed in `readonly_fields`, so it can never be filled in and saving fails. | | `content_studio.W003` | A choice widget (`SelectWidget`, `MultiSelectWidget`, `ToggleGroupWidget`, `RadioButtonWidget`) on a field without choices, which would show empty. | | `content_studio.W004` | `sortable_by` names that are not `table_display` columns. Sorting only applies to table columns. | | `content_studio.W005` | `list_filter` entries that are not field names, such as filter classes or tuples. They are ignored. | | `content_studio.W006` | A model group with a model that is not registered with the site, which leaves an empty menu entry. | | `content_studio.W007` | A media item model without `file`, `name` or `type`, or a folder model without `name` or `parent`. | | `content_studio.W008` | A `crop_image` method on the site without a `MEDIA_LIBRARY_MODEL`, so it is never called. | | `content_studio.W009` | An empty `LOGIN_BACKENDS` setting, so nobody can log in. | | `content_studio.W010` | `list_display` used for the table columns. It works, but rename it to `table_display`. When both are set, `list_display` is ignored. Inlines are checked too. | | `content_studio.W011` | A studio that overrides `get_list_display`, which is never called. The list view uses `get_list_item_display`. | | `content_studio.W012` | A dashboard widget that does not subclass a built-in widget, which the admin cannot show. | | `content_studio.W013` | An `ordering` that does not start with the `reorder_field` (after any `reorder_scope` fields), so moves do not match the order of the list. Inlines are checked too. | | `content_studio.W014` | `allow_duplication` on a singleton, or `duplicate_inlines` without `allow_duplication`. Neither has an effect. | | `content_studio.W015` | `export_fields` or `allow_export` on a singleton, an empty `export_fields`, or exported columns that are in `exclude` and are left out. None of them has an effect. | | `content_studio.W016` | [Presence](https://djangocontentstudio.org/docs/presence.md) kept in a `DummyCache`, which keeps nothing, so the admin never shows who has an object open. | | `content_studio.W017` | [Presence](https://djangocontentstudio.org/docs/presence.md) kept in a `LocMemCache`, which each process keeps for itself. A deployment check: `manage.py check --deploy` reports it. | | `content_studio.W018` | [Password reset](https://djangocontentstudio.org/docs/authentication.md#password-reset) emails sent during the request, because no task backend runs Django tasks in a worker. A deployment check: `manage.py check --deploy` reports it. | | `content_studio.W019` | `CoverImageWidget` on a field that is not a `MediaField` to the media library model. The widget edits one media item. | | `content_studio.W020` | A [Huey task dashboard](https://djangocontentstudio.org/docs/dashboard.md#huey) without Huey's stats app, so it cannot show runs, durations or errors. | | `content_studio.W021` | A [`NotificationsWidget`](https://djangocontentstudio.org/docs/notifications.md#a-dashboard-widget) on a site without `content_studio.notifications` in `INSTALLED_APPS`; it is not shown. | | `content_studio.W022` | [`IMAGE_SIZES`](https://djangocontentstudio.org/docs/settings.md#image_sizes) set while no sizes can be made: Pillow is not installed, `IMAGE_SIZE_FOLDER` is not set, or the media model has no metadata field. Every size is then the image's own file. | | `content_studio.W023` | An analytics provider missing a setting it needs, such as an [`UmamiProvider`](https://djangocontentstudio.org/docs/analytics.md#umami) without an `api_key` or `website_id`. Its reports show the statistics as unavailable. | | `content_studio.W024` | A required model field that only shows under a [condition](https://djangocontentstudio.org/docs/edit-page.md#conditional-fields). A hidden field is not saved, so creating an object while it is hidden fails. | ## Reporting a vulnerability Report security problems privately, through GitHub's private vulnerability reporting or by email; see [SECURITY.md](https://github.com/dwarsbit/django-content-studio/blob/main/SECURITY.md). Source: https://djangocontentstudio.org/docs/deployment --- # Settings reference All settings live in one `CONTENT_STUDIO` dictionary in your Django settings. Every setting is optional; leave out the ones you do not change. ```python # mysite/settings.py CONTENT_STUDIO = { "ADMIN_SITE": "mysite.studio.admin_site", "MEDIA_LIBRARY_MODEL": "content_studio.media_library.models.Media", } ``` ## Site ### ADMIN_SITE The site the admin runs on, as an import path string. Default: `"content_studio.admin.admin_site"`, a site without customizations. To add a dashboard, extensions, tenants or media library hooks, subclass `ContentStudioSite` and point this setting at your instance. ## Login and sessions ### LOGIN_BACKENDS The login backends. Leave it at the default, username and password: it is the only supported backend ([authentication](https://djangocontentstudio.org/docs/authentication.md#login-backends)). ### LOGIN_THROTTLE_RATE The maximum number of login attempts per IP address, as a [Django REST Framework rate string](https://www.django-rest-framework.org/api-guide/throttling/#setting-the-throttling-policy) such as `"10/min"`. Default: `"10/min"`. `None` removes the limit. Attempts are counted in the cache: with the default per-process cache, every worker counts separately, so configure a shared cache. ### TOKEN_BACKEND The backend that manages sessions. Leave it at the default: it is the only supported backend. ### TOKEN_LIFETIME How long a session stays active between renewals, in seconds. Default: `3600` (one hour). Sessions renew themselves while the editor works, so a short lifetime does not log anyone out. ### REFRESH_TOKEN_LIFETIME How long a session can go unused before the editor has to log in again, in seconds. Default: `604800` (seven days). ### MAX_SESSION_LIFETIME How long a session can last after logging in, however active the editor is, in seconds. Default: `2592000` (30 days). After that, the editor logs in again. `None` lets a session that is in use last indefinitely. ### TOKEN_CACHE The alias of the cache in `CACHES` that remembers logged-out sessions. Default: `"default"`. With several worker processes or servers, use a shared cache, such as Redis, Memcached or the database cache; otherwise a session logged out in one worker continues in another. ### TOKEN_SIGNING_KEY The key that signs the sessions. Default: `None`, which uses Django's `SECRET_KEY`. Rotate `SECRET_KEY` the way Django documents, with the old key in `SECRET_KEY_FALLBACKS`, and the sessions it signed continue; without the fallback, every editor logs in again. Set a separate key if you rotate `SECRET_KEY` independently of admin sessions. ### TOKEN_ALGORITHM The algorithm that signs the sessions: `"HS256"`, `"HS384"` or `"HS512"`. Default: `"HS256"`. ### REFRESH_COOKIE_SECURE Whether the session cookie is marked `Secure`, so browsers only send it over HTTPS. Default: `None`, which marks it secure on HTTPS requests and whenever `DEBUG` is off, also behind a proxy that does not set `SECURE_PROXY_SSL_HEADER`. Set `True` or `False` to force either value. A production site served over plain HTTP needs `False` to log in at all, and should move to HTTPS. ## Audit stamps ### EDITED_BY_ATTR, EDITED_AT_ATTR, CREATED_BY_ATTR, CREATED_AT_ATTR The field names of the audit stamps. Defaults: `"edited_by"`, `"edited_at"`, `"created_by"` and `"created_at"`. When an editor creates an object, the admin fills in `created_by`; when they save it, `edited_by` and `edited_at`. Models without these fields are left as they are. ## Content ### HTML_SANITIZER The sanitizer that cleans [`HTMLField`](https://djangocontentstudio.org/docs/registering-models.md) values on save: a callable or an import path string. Default: `None`, the built-in sanitizer, which keeps a common set of tags, attributes and link types and removes everything else. A `sanitizer` passed to an `HTMLField` takes precedence over this setting. ### UPLOAD_FOLDER Where files picked in a [`FileWidget`](https://djangocontentstudio.org/docs/widgets.md#files) wait until the editor saves, in the default storage. Default: `"content_studio/uploads"`. `content_studio_clear_uploads` removes the ones that were never saved. ### UPLOAD_MAX_SIZE The maximum size of a file picked in a `FileWidget`, in bytes. Default: `52428800` (50 MB). `None` removes the limit; your web server's own request size limit still applies. ### MAP_TILE_URL, MAP_TILE_ATTRIBUTION The tiles a [`MapWidget`](https://djangocontentstudio.org/docs/widgets.md#maps) shows: a tile URL template with `{z}`, `{x}` and `{y}`, and the attribution shown on the map (HTML). Default: OpenStreetMap's tiles, `"https://tile.openstreetmap.org/{z}/{x}/{y}.png"`, with its attribution. Editors' browsers request the tiles, so a tile server with a key, such as MapTiler, exposes that key to them. ### MAP_GEOCODER_URL The address search of a `MapWidget`: a [Nominatim](https://nominatim.org) search URL, which editors' browsers call when they press Enter in the search box. Default: `"https://nominatim.openstreetmap.org/search"`. `None` leaves the search box out. ### OEMBED_PROVIDERS The sites an [`EmbedWidget`](https://djangocontentstudio.org/docs/widgets.md#embeds) previews. Default: `None`, the built-in list (`content_studio.oembed.PROVIDERS`): YouTube, Vimeo, Spotify, SoundCloud, Dailymotion, TikTok, Flickr, Bluesky, X, Mixcloud, CodePen and Giphy. A list replaces it; each entry names the site, its oEmbed endpoint and the URL patterns it embeds, as regular expressions matched against the start of the URL: ```python from content_studio.oembed import PROVIDERS CONTENT_STUDIO = { "OEMBED_PROVIDERS": [ *PROVIDERS, { "name": "PeerTube", "endpoint": "https://tube.example.com/services/oembed", "patterns": [r"https://tube\.example\.com/w/"], }, ], } ``` The server asks these endpoints, and nothing else, for a preview. ## Media library ### MEDIA_LIBRARY_MODEL, MEDIA_LIBRARY_FOLDER_MODEL The media item and folder models of the [media library](https://djangocontentstudio.org/docs/media-library.md), as import path strings. Default: `None`, which turns the media library off. ### MEDIA_LIBRARY_ALLOWED_EXTENSIONS The file extensions the media library accepts, in lowercase and without the dot. By default: - images: `avif`, `gif`, `jpeg`, `jpg`, `png`, `webp`; - video and audio: `m4a`, `mov`, `mp3`, `mp4`, `ogg`, `wav`, `webm`; - documents: `csv`, `doc`, `docx`, `odp`, `ods`, `odt`, `pdf`, `ppt`, `pptx`, `txt`, `xls`, `xlsx`. SVG is left out because it can contain scripts. `None` accepts every extension. Files whose content is markup (HTML, SVG, XML) are rejected whatever their extension. ### MEDIA_LIBRARY_MAX_UPLOAD_SIZE The maximum size of an upload, in bytes. Default: `52428800` (50 MB). `None` removes the limit; your web server's own request size limit still applies. ### MEDIA_METADATA_ATTR The field on the media item model that stores the [metadata read from its file](https://djangocontentstudio.org/docs/media-library.md#metadata), such as an image's width and height. The field must be a `JSONField`; editors cannot change it. Default: `"metadata"`, the field of the built-in `Media` model. A media model without a `JSONField` by that name has no metadata; `None` turns reading metadata off. When you set this setting, system check `content_studio.E025` reports a value without a media library model, or one naming a field that is missing or not a `JSONField`. ### MEDIA_PROPERTIES_ATTR The field on the media item model that stores the [properties editors set](https://djangocontentstudio.org/docs/media-library.md#properties) for some types of media, such as an image's focal point. The field must be a `JSONField`. Default: `"properties"`, the field of the built-in `Media` model. A media model without a `JSONField` by that name has no properties, and the admin offers no focal point; `None` turns properties off. When you set this setting, system check `content_studio.E026` reports a value without a media library model, or one naming a field that is missing or not a `JSONField`. ### THUMBNAIL_FOLDER The folder, inside each image's storage, that the [thumbnails of media library images](https://djangocontentstudio.org/docs/media-library.md#thumbnails) are saved in. Default: `"content_studio/thumbnails"`. Thumbnails need Pillow, from the `images` extra. `None` turns them off: the admin shows the image file itself. ### THUMBNAIL_CACHE The alias of the cache in `CACHES` that remembers which thumbnails exist. Default: `"default"`. With several worker processes or servers, it must be a shared cache, such as Redis, Memcached or the database cache. System check `content_studio.E027` reports an alias that is not in `CACHES` while thumbnails are on. ### IMAGE_SIZES The [image sizes](https://djangocontentstudio.org/docs/media-library.md#image-sizes) every media library image is made in, for your site and other consumers: a dict of size names to options (`width`, `height`, `fit`, `format`, `quality` and `upscale`). Default: `{}`, which makes none. Names hold letters, digits, hyphens and underscores. System check `content_studio.E033` reports a size it cannot make, and `content_studio.W022` sizes that are set while none can be made. ### IMAGE_SIZE_FOLDER The folder, inside each image's own storage, that the image sizes are saved in. Default: `"content_studio/sizes"`. ## Multi-tenancy ### TENANT_MODEL The tenant model for [multi-tenancy](https://djangocontentstudio.org/docs/multi-tenancy.md), as an import path string. Default: `None`, which turns multi-tenancy off. ## Presence ### PRESENCE Whether the edit and list pages show which other editors have an object open; see [Presence](https://djangocontentstudio.org/docs/presence.md). Default: `True`. A studio turns it off for its model with `presence = False`. ### PRESENCE_CACHE The alias of the cache in `CACHES` that presence is kept in. Default: `"default"`. With several worker processes or servers, it must be a shared cache, such as Redis, Memcached or the database cache. System check `content_studio.E023` reports an alias that is not in `CACHES`. ## Webhooks These settings apply when the `content_studio.webhooks` app is installed; see [Webhooks](https://djangocontentstudio.org/docs/webhooks.md). ### WEBHOOK_ALLOWED_HOSTS Host names that webhooks may reach although they resolve to a private, loopback or link-local address, or use plain HTTP, such as a build server on your own network. Default: `[]`. Outside this list, webhooks only connect to public addresses, over HTTPS unless `DEBUG` is on. ### WEBHOOK_MAX_ATTEMPTS How many times a webhook delivery is attempted before it is marked as failed. Default: `5`. The wait between attempts grows from 1 minute to 64 minutes. ### WEBHOOK_TIMEOUT How long a webhook request may take in total, from connecting to reading the response, in seconds. A request that takes longer fails and is retried. Default: `5`. ### WEBHOOK_DELIVERY_RETENTION How many days delivered and failed webhook deliveries are kept. `content_studio_deliver_webhooks` removes older ones; pending deliveries are always kept. Default: `30`. `None` keeps every delivery. ## Notifications ### NOTIFICATION_RETENTION How many days read notifications are kept. `content_studio_prune_notifications` removes older ones. Default: `90`. `None` keeps every read notification. ### NOTIFICATION_UNREAD_RETENTION How many days unread notifications are kept. `content_studio_prune_notifications` removes older ones. Default: `None`, which keeps every unread notification. ### NOTIFICATION_EMAIL_DIGEST Whether `content_studio_send_notification_digest` emails editors the notifications they left unread. It needs [`ADMIN_URL`](#admin_url) for the links in the emails. Default: `False`. See [Email digest](https://djangocontentstudio.org/docs/notifications.md#email-digest). ### ADMIN_URL The admin's absolute URL, like `"https://example.com/admin/"`, for links in emails, which are sent outside a request. Default: `None`. ## Password reset ### PASSWORD_RESET_EXPIRATION_TIME How long a password reset code stays valid, in minutes. Default: `10`. ### PASSWORD_RESET_THROTTLE_RATE The maximum number of password reset requests per IP address, as a [Django REST Framework rate string](https://www.django-rest-framework.org/api-guide/throttling/#setting-the-throttling-policy). Default: `"10/hour"`. `None` removes the limit. Like the login limit, it is counted in the cache. Source: https://djangocontentstudio.org/docs/settings --- # Public API Content Studio follows [semantic versioning](https://semver.org/) for its public API: from version 1.0, the names on this page only change in a major release, after a deprecation period with a warning. The public API consists of: - the names below, imported from the module they are listed under; - the documented attributes and methods of `ModelStudio`, `InlineModelStudio` and `ContentStudioSite`: the configuration options and the hooks, such as the `has_*_permission` methods, `get_fields`, `get_readonly_fields`, `get_prepopulated_fields`, `get_list_item_display`, `get_relation_display`, `get_created_result`, `get_duplicate_initial`, `get_tenants`, `get_thumbnail`, `get_media_serializer_fields`, `get_media_metadata`, `get_default_subscriptions`, `crop_image` and `reset_crop`; - the documented options and methods of blocks: the `Meta` options of `Block`, `get_context` and `get_template_name`, and the [values of a block field](https://djangocontentstudio.org/docs/block-fields.md#render-blocks-in-templates) (`blocks`, and each block's `id`, `type`, `value` and `render`); - the `render_blocks`, `image_size` and `image_url` tags of the `content_studio` template library; - every [setting](https://djangocontentstudio.org/docs/settings.md) in `CONTENT_STUDIO`; - the [system check](https://djangocontentstudio.org/docs/deployment.md#system-checks) IDs, but not their messages; - `content_studio.urls` and the `content_studio.contrib.password_reset` app, as a whole; - the media library's `content_studio_media_metadata`, `content_studio_thumbnails` and `content_studio_image_sizes` commands, with their `--all` option; - the `content_studio.webhooks` app's `content_studio_deliver_webhooks` command, and the requests its webhooks send: the payload with its `version`, and the `X-Content-Studio-*` headers; - the `content_studio.notifications` app's `content_studio_prune_notifications` command, and its `content_studio_send_notification_digest` command with its `--delay` option; - the email digest's templates, `content_studio/notifications/digest.txt` and `digest.html`, and the context they get. Everything else may change in any release, including any name that is not on this page, even when you can import it. ## `content_studio` The registration decorator and the package version. - `VERSION` - `register` ## `content_studio.admin` Studios, the site and their configuration objects. `admin_site` is the default site. - `ContentStudioSite` - `InlineModelStudio` - `ListItemDisplay` - `ModelGroup` - `ModelStudio` - `RelationDisplay` - `admin_site` - `register` ## `content_studio.form` Edit page layouts, components and table columns. `Component` is the base type of the built-in components; custom component types are not supported, but `Link`, `ButtonLink`, `Button`, the text components (`Title`, `Subtitle`, `Text`, `Callout`) and `SEOPreview` are meant to be subclassed. `Condition` is the base of [server-side conditions](https://djangocontentstudio.org/docs/edit-page.md#server-side-conditions). - `ActivityLog` - `Button` - `ButtonLink` - `ButtonResult` - `Callout` - `Component` - `Condition` - `Field` - `FieldLayout` - `FormSet` - `FormSetGroup` - `Link` - `SEOPreview` - `Subtitle` - `TableColumn` - `Text` - `Title` ## `content_studio.actions` Custom bulk actions. - `bulk_action` ## `content_studio.dashboard` The dashboard and its layout widget. `DashboardWidget` is the base type of the built-in widgets. - `Dashboard` - `DashboardWidget` - `SpacingWidget` ## `content_studio.dashboard.*` The built-in dashboard widgets, each from its own module. - `content_studio.dashboard.statistic.StatisticWidget` - `content_studio.dashboard.content_list.ContentListWidget` - `content_studio.dashboard.activity_log.ActivityLogWidget` - `content_studio.dashboard.scheduled_tasks.ScheduledTasksWidget` ## `content_studio.dashboard.tasks` The task dashboard: the adapter you write for your task backend, the widgets it feeds and the page that shows them. - `TaskAdapter` - `TaskDashboard` - `TaskListWidget` - `TaskRow` - `TaskStatistic` - `TaskStatisticWidget` - `TaskStatus` ## `content_studio.contrib.huey` The task dashboard's adapter for Huey. - `HueyAdapter` ## `content_studio.analytics` [Analytics](https://djangocontentstudio.org/docs/analytics.md): the extension, its page, links, component and dashboard widgets, the Umami provider, and the interface of a provider of your own. `get_analytics` returns the site's `AnalyticsExtension`, or `None`. - `Analytics` - `AnalyticsError` - `AnalyticsExtension` - `AnalyticsLink` - `AnalyticsPage` - `AnalyticsProvider` - `AnalyticsQuery` - `AnalyticsStatisticWidget` - `AnalyticsTopPagesWidget` - `AnalyticsUserMenuLink` - `BreakdownRow` - `MetricValue` - `TimeseriesPoint` - `UmamiProvider` - `get_analytics` ## `content_studio.extensions` Menu links, menu buttons, embedded pages and dashboard pages. - `DashboardPage` - `IFramePage` - `MainMenuButton` - `MainMenuLink` - `UserMenuButton` - `UserMenuLink` ## `content_studio.widgets` The built-in editing widgets, to use in `Field(widget=...)` and `default_widget_mapping`, as classes or as instances with [options](https://djangocontentstudio.org/docs/widgets.md#widget-options). `BaseWidget` is their base type; custom widgets are not supported. - `BaseWidget` - `BlockWidget` - `CheckboxWidget` - `CodeWidget` - `ColorPickerWidget` - `CoverImageWidget` - `DateTimeWidget` - `DateWidget` - `DurationWidget` - `EmailWidget` - `EmbedWidget` - `FileWidget` - `ForeignKeyWidget` - `IconPickerWidget` - `InputWidget` - `JSONSchemaWidget` - `JSONWidget` - `KeyValueWidget` - `ListWidget` - `ManyMediaWidget` - `ManyToManyWidget` - `MapWidget` - `MarkdownWidget` - `MediaWidget` - `MultiSelectWidget` - `PasswordWidget` - `PhoneWidget` - `RadioButtonWidget` - `RatingWidget` - `RichTextWidget` - `SelectWidget` - `SliderWidget` - `SlugWidget` - `SwitchWidget` - `TagWidget` - `TextAreaWidget` - `TimeWidget` - `ToggleGroupWidget` - `URLPathWidget` - `URLWidget` ## `content_studio.formats` The built-in display formats, to use in `TableColumn(format=...)` and `default_format_mapping`, as classes or as instances. `BaseFormat` is their base type; custom formats are not supported. - `BadgeFormat` - `BaseFormat` - `BlockFormat` - `BooleanFormat` - `CodeFormat` - `ColorFormat` - `CurrencyFormat` - `DateFormat` - `DateTimeFormat` - `DurationFormat` - `EmailFormat` - `FileFormat` - `FileSizeFormat` - `ForeignKeyFormat` - `HtmlFormat` - `ImageFormat` - `JSONFormat` - `LinkFormat` - `ManyMediaFormat` - `ManyToManyFormat` - `MediaFormat` - `NumberFormat` - `PercentFormat` - `ProgressFormat` - `TagFormat` - `TextFormat` - `TimeFormat` ## `content_studio.fields` Optional content fields. - `ColorField` - `FlexField` - `HTMLField` - `HashTagField` - `MultipleChoiceField` - `TagField` - `URLPathField` ## `content_studio.blocks` The block field: an ordered list of content blocks. `Block` is the base type of the blocks you define. - `Block` - `BlockField` ## `content_studio.models` Optional abstract base models. - `ContentModel` - `EditorModel` - `ManualOrderModel` - `SingletonModel` - `SoftDeletableContentModel` - `SoftDeletableModel` - `TimeStampedModel` - `UUIDModel` ## `content_studio.managers` Managers of the base models. - `SingletonManager` - `SoftDeletableManager` - `SoftDeletableQuerySet` ## `content_studio.signals` The signal sent after changes made in the admin are committed, the changes it carries, and the function that records a change. - `Change` - `content_changed` - `record_change` ## `content_studio.validators` Validators used by the content fields, usable on any field. - `ChoiceOptionsValidator` - `ColorValidator` - `JSONSchemaValidator` ## `content_studio.sanitizers` The built-in HTML sanitizer, the default `HTML_SANITIZER`. - `sanitize_html` ## `content_studio.exceptions` Raised by registration. - `AlreadyRegistered` - `NotRegistered` ## `content_studio.media_library.fields` Media picker fields of the optional media library app. - `CropField` - `ManyMediaField` - `MediaField` ## `content_studio.media_library.models` The media library app's concrete models. - `Folder` - `Media` ## `content_studio.media_library.sizes` The [image sizes](https://djangocontentstudio.org/docs/media-library.md#image-sizes) of media library images. - `get_image_sizes` ## `content_studio.webhooks.models` The webhooks app's models: the webhooks and their deliveries. - `Webhook` - `WebhookDelivery` ## `content_studio.webhooks.dashboard` The webhooks app's dashboard widget. - `WebhookDeliveriesWidget` ## `content_studio.webhooks.extensions` The webhooks app's main menu button. - `DeployButton` ## `content_studio.notifications` The notifications app's events: register an event, and notify users of it. - `notify` - `register_event` ## `content_studio.notifications.dashboard` The notifications app's dashboard widget. - `NotificationsWidget` ## `content_studio.notifications.models` The notifications app's models: the notifications, the users' subscriptions and the objects they follow. - `Follow` - `Notification` - `NotificationSubscription` Source: https://djangocontentstudio.org/docs/public-api --- # AI agents AI coding agents, such as Claude Code, Codex or Cursor, can set up and configure Content Studio for you. Give them the documentation in a form they read well: an index and a full text of the docs, and an agent skill with the setup, the main APIs and the mistakes to avoid. ## The documentation as text The docs website publishes the documentation as plain Markdown, generated from the same pages you are reading: - [`/llms.txt`](https://djangocontentstudio.org/llms.txt): an index of every page with a short summary, in the [llms.txt](https://llmstxt.org) format. - [`/llms-full.txt`](https://djangocontentstudio.org/llms-full.txt): every page in one file. - Each page as Markdown: add `.md` to its address, such as `https://djangocontentstudio.org/docs/edit-page.md`. Point your agent at `https://djangocontentstudio.org/llms.txt`, or paste a page's Markdown address into the conversation. ## The agent skill The agent skill is a short guide for agents: how to install Content Studio, register models, lay out the edit page, pick widgets and formats, write conditional fields, and set up the dashboard, menus, extensions and analytics, with the mistakes the system checks report most. It links to the pages above for everything else. The skill is published at [`/skills/django-content-studio/SKILL.md`](https://djangocontentstudio.org/skills/django-content-studio/SKILL.md). For Claude Code, save it in your project, where Claude Code loads it when a task involves Content Studio: ```bash mkdir -p .claude/skills/django-content-studio curl -o .claude/skills/django-content-studio/SKILL.md \ https://djangocontentstudio.org/skills/django-content-studio/SKILL.md ``` Other agents that support skills load it from their own skills folder. For an agent without skills, add the file's address to your project's `AGENTS.md`. The skill and the text versions follow the current documentation. Download the skill again after you upgrade Content Studio. ## Check the result Agents make the same mistakes people do: a required field left off the edit page, a column that is not a field, two buttons with the same ID. Run the [system checks](https://djangocontentstudio.org/docs/deployment.md#system-checks) after every change, and let the agent fix what they report: ```bash python manage.py check ``` Source: https://djangocontentstudio.org/docs/ai-agents --- # How it compares Several projects improve on or replace the classic Django admin. They solve different problems, so the right choice depends on what you build. This page describes how Content Studio differs from the most common ones. ## At a glance | | Approach | Manages | Renders your website | |---|---|---|---| | Content Studio | A separate admin, configured with studios | Your Django models | No | | Classic Django admin | Django's built-in admin, configured with `ModelAdmin` | Your Django models | No | | Django Unfold | A theme and extensions for the classic Django admin | Your Django models | No | | Jazzmin, Grappelli | Themes for the classic Django admin | Your Django models | No | | Wagtail | A CMS with its own admin | Pages in a page tree, plus snippets and other models | Yes | | django CMS | A CMS with editing on the page itself | Pages, placeholders and plugins | Yes | ## Admin themes: Django Unfold, Jazzmin and Grappelli These projects keep the classic Django admin and change how it looks, and in Unfold's case add features such as dashboards and extra filters. You keep configuring `ModelAdmin` classes, and everything that works with the classic Django admin keeps working. Content Studio is not a theme: it is an admin of its own. You configure it with studios, which follow the conventions of `ModelAdmin` where they can (permission hooks, `search_fields`, `list_filter`, `ordering`, inlines) and differ where the admin needs more (widgets, layouts, list views, a media library). It runs next to the classic Django admin, so you can move over one model at a time. ## Content management systems: Wagtail and django CMS Wagtail and django CMS are content management systems. Content is organized as pages in a tree, and the CMS renders those pages on your website; django CMS also lets editors change content on the page itself. You model content the way the CMS expects, as page types or plugins. Content Studio does not render your website and does not ask you to model content in a particular way. It manages the Django models you already have, and your own views and templates, or an API, deliver the content. The built-in fields and models are optional. ## When to use Content Studio Content Studio fits when you want a modern admin for an existing or new Django project, keep full control over your models and how content reaches your users, and need content features such as rich text, a media library, a dashboard or multi-tenancy without adopting a CMS. Source: https://djangocontentstudio.org/docs/comparison