Extending Vantage¶
Three extension points, in the order you are likely to need them.
| You want to | Use |
|---|---|
| Put your own HTML on a Vantage page | Slots |
| Ship an extension as an installable package | Plugins |
Keep the admin.py you already have |
The contrib.admin adapter |
Slots¶
A slot is a named place in a Vantage template that you can fill from Python. It exists so that adding one banner does not mean overriding — and then maintaining — a whole template.
The eight slots¶
| Name | Where |
|---|---|
before_list / after_list |
above / below the grid on a list page |
before_form / after_form |
above / below a create or edit form |
object_actions |
the button row on a detail page |
row_actions |
a trailing cell on every list row |
detail_sidebar |
beside the fields on a detail page |
dashboard_top |
above the dashboard cards |
Filling one¶
Anywhere that runs at startup — panel.py is the natural home:
# blog/panel.py
from django.utils.html import format_html
from vantage import slots
# A template fragment.
slots.add("before_list", template="blog/_import_banner.html")
# Or a callable. It receives the template context as a plain dict.
@slots.slot("object_actions")
def preview_button(context):
post = context["object"]
return format_html('<a class="v-btn v-btn-ghost" href="{}">Preview</a>', post.get_absolute_url())
Contributions render in registration order. Pass order=-10 to sit ahead of everything
registered by someone else — that is how a plugin gets in front of project code without
either importing the other.
Escaping¶
A callable's return value is escaped unless you marked it safe:
slots.add("before_list", render=lambda ctx: "<b>hi</b>") # renders <b>hi</b>
slots.add("before_list", render=lambda ctx: format_html("<b>hi</b>")) # renders <b>hi</b>
A template= contribution is not escaped again — the template engine already escaped
its own variables on the way through.
Permissions¶
slots.add("row_actions", template="blog/_retry.html", permission="blog.change_post")
slots.add("dashboard_top", render=beta_banner, permission=lambda request: request.user.is_staff)
A string goes to request.user.has_perm; a callable receives the request. A gated
contribution renders nothing when there is no request in context — it fails closed.
Scoping to one model¶
Or, for content that belongs with the panel, implement the hook instead:
@register(Post)
class PostPanel(ModelPanel):
def slot(self, name, context):
if name == "detail_sidebar":
return render_to_string("blog/_seo_card.html", context)
return None
The panel's own content renders first, then the registry's.
Failure¶
One contribution raising does not take the page down: the exception is logged to the
vantage.slots logger and that fragment is skipped. Its siblings still render.
Using a slot in your own template¶
If you override a Vantage template and want a slot of your own:
{% load vantage_slots %}
{% slot "my_sidebar" %}
{# For markup that should only exist when the slot has content: #}
{% slot_filled "row_actions" as has_actions %}
{% if has_actions %}<td>{% slot "row_actions" object=row.obj %}</td>{% endif %}
Undeclared names are rejected at registration, because a mistyped slot name is otherwise invisible — the fragment simply never appears.
Plugins¶
A plugin is a package that extends the panel by being installed.
Writing one¶
# vantage_celery/__init__.py
from vantage import slots
from vantage.plugins import VantagePlugin
from vantage_celery.pages import QueuePage
class CeleryPlugin(VantagePlugin):
name = "celery"
version = "1.2.0"
def register(self, site):
site.register_page(QueuePage)
slots.add("dashboard_top", template="vantage_celery/_queue_depth.html")
# vantage_celery/pyproject.toml
[project.entry-points."vantage.plugins"]
celery = "vantage_celery:CeleryPlugin"
pip install vantage-celery is now the whole installation. No settings change, no
INSTALLED_APPS entry.
register(site) runs inside AppConfig.ready(), so it must not touch the database — a
query there breaks migrate on a fresh install.
An entry point may also point at an instance, or at a bare function taking the site:
Registering one by hand¶
For a plugin you vendor, or if you would rather see your extensions written down:
# project/panel.py
from vantage.plugins import register_plugin
from vantage_celery import CeleryPlugin
register_plugin(CeleryPlugin)
This loads immediately, and is a no-op if the same plugin was already discovered — so doing both is harmless.
Turning one off¶
VANTAGE = {
"PLUGINS": ["celery", "audit"], # allowlist; None (the default) loads everything
"PLUGINS_EXCLUDE": ["celery"], # denylist, and it wins
}
Names match either the entry-point name or the plugin's own name. An excluded plugin
is never imported, which is what makes the denylist useful when the reason for
switching it off is that importing it fails.
When a plugin is broken¶
Nothing is fatal. A plugin that fails to import, or whose register hook raises, is
skipped and reported by manage.py check:
WARNINGS:
?: (vantage.E030) The Vantage plugin 'celery' failed to load and was skipped.
HINT: ImportError: No module named 'kombu'
Declared at vantage_celery:CeleryPlugin.
Fix the plugin, or add 'celery' to VANTAGE['PLUGINS_EXCLUDE'] to run without it.
The alternative — raising in ready() — means no shell, no migrate, and no way to
uninstall the thing that broke.
Loading is idempotent, so a second ready() (the autoreloader, a test that reconfigures
settings) does not give you two copies of every page.
The contrib.admin adapter¶
Import an existing ModelAdmin and get a working panel. Your admin.py does not
change, and both panels keep working side by side.
Everything at once¶
# project/panel.py
from vantage import site
from vantage.contrib.django_admin import adopt_admin_site
adopt_admin_site(site)
Every model registered with django.contrib.admin now has a Vantage panel. Models you
have already given a hand-written ModelPanel are left alone; pass replace=True if
you want the admin to win instead.
Call it from panel.py so it runs before auto-registration.
One model at a time¶
from vantage import ModelPanel, register
from vantage.contrib.django_admin import from_admin
from blog.admin import PostAdmin
from blog.models import Post
@register(Post)
@from_admin(PostAdmin)
class PostPanel(ModelPanel):
icon = "file-text" # anything you declare here wins
list_per_page = 100
If PostAdmin is not registered with contrib.admin, name the model:
@from_admin(PostAdmin, Post).
What translates¶
ModelAdmin |
Becomes |
|---|---|
list_display |
list_display — including methods on the admin, and __str__ |
list_display_links |
list_link (the first entry) |
list_filter |
list_filter (field entries) |
search_fields |
list_search |
ordering, list_per_page |
ordering, list_per_page |
list_select_related |
list_select_related (True gives way to Vantage's own planner) |
list_editable |
list_editable |
fields, exclude, readonly_fields |
the same, with fields flattened |
fieldsets |
a Section / Row layout, classes=("collapse",) included |
date_hierarchy |
a date filter on that field |
actions |
Vantage actions, with short_description and permissions |
has_view/add/change/delete_permission |
the same methods on the panel |
get_queryset, save_model |
delegated to the admin — the signatures match |
Only what the admin actually declared is translated. An attribute left at its
ModelAdmin default stays unset on the panel, so Vantage's inference still runs:
adopting an admin that sets only search_fields does not cost you inferred columns,
filters and widgets.
has_*_permission is consulted before the superuser bypass, matching
contrib.admin: an admin that denies deletion denies it to everyone.
What does not translate¶
Nothing is dropped silently. Each of these is reported per model by manage.py check
as vantage.W040:
inlines— not translated automatically;ModelAdmin.InlineModelAdminclasses do not carry across. Vantage has its own, differently-shaped mechanism —ModelPanel.inlines— but it is declared by hand on the new panel rather than inferred from the adoptedModelAdmin.formfield_overrides— Vantage infers widgets from the field.raw_id_fields,autocomplete_fields,filter_horizontal,filter_vertical,radio_fields,prepopulated_fields— widget choices; Vantage picks its own.get_urls()— register aPageon the Vantage site instead.Media— useVANTAGE["CUSTOM_CSS"].formand the custom templates (change_form_templateand friends).- The view and form hooks —
get_form,get_fieldsets,get_list_display,get_search_results,get_actions,changelist_view,change_view,response_add,formfield_for_foreignkey,lookup_allowed, and the rest: behaviour that lives in Python the adapter cannot read. SimpleListFilterand list-filter classes — the field stays filterable with Vantage's own control; the custom class does not come across.short_descriptionon a display method — the column header falls back to the method name.- Computed
readonly_fields— excluded from the form, but not displayed. - A custom action permission (
@admin.action(permissions=["approve"])) — the action falls back to requiringchange, and says so. - Cosmetics —
save_as,save_on_top,view_on_site,empty_value_display,list_max_show_all,show_full_result_count,actions_on_top/_bottom,paginator,sortable_by.
WARNINGS:
<class 'PostAdminPanel'>: (vantage.W040) PostAdmin was adopted for blog.Post, but
4 setting(s) could not be translated.
HINT: Not carried across:
· inlines (inline formsets — edit the related model from its own page, …)
· raw_id_fields (raw id inputs — Vantage uses a searching select for relations)
· get_urls() (extra URLs hung off the admin — register a Page on the Vantage site)
· Media (extra CSS/JS — add it with VANTAGE['CUSTOM_CSS'])
contrib.admin is optional¶
Nothing in the core imports it. adopt_admin_site() with no arguments soft-detects with
apps.is_installed("django.contrib.admin") and does nothing — loudly, in the log — when
it is absent. Passing an AdminSite explicitly skips the detection, which is what you
want for a custom admin site:
Settings reference¶
VANTAGE = {
"PLUGINS": None, # None = load every discovered plugin; a list = allowlist
"PLUGINS_EXCLUDE": [], # never loaded, whatever PLUGINS says
}
Check IDs¶
| ID | Meaning |
|---|---|
vantage.E030 |
A plugin failed to load and was skipped |
vantage.W030 |
VANTAGE["PLUGINS"] names a plugin nothing provides |
vantage.W040 |
A ModelAdmin was adopted with settings that could not be translated |