Package: django-baton
django-batonA cool, modern and responsive django admin application based on bootstrap 5Documentation:readthedocsLive DemoNow you can try django-baton using the new shining live demo!
Login with userdemoand passworddemohttps://django-baton.sqrt64.it/Last changesBaton 3.0.* removes theanalyitcsmodule!It also introduces a cool option, which is enabled by default, and displays the changeform submit row fixed at the bottom on large screens.It fixes some bugs.Table of contentsFeaturesInstallationConfigurationMenuSearch FieldPage DetectionSignalsJs UtilitiesJs TranslationsList FiltersChangelist IncludesChangelist Filters IncludesChangelist Row AttributesForm TabsForm IncludesCollapsable stacked inlines entriesCustomizationTestsContributingScreenshotsFeaturesSupports Django >= 2.1. For older versions of Django, please use django-baton@1.13.2.This application was written with one concept in mind: overwrite as few django templates as possible.
Everything is styled through CSS and when required, JS is used.Based on Bootstrap 5 and FontAwesome Free 6Fully responsiveCustom and flexible sidebar menuConfigurable search fieldText input filters and dropdown list filters facilitiesForm tabs out of the boxEasy way to include templates in the change form and change list pagesEasy way to add attributes to change list table rows/cellsCollapsable stacked inline entriesLazy loading of uploaded imagesOptional display of changelist filters in a modalOptional use of changelist filters as a form (combine some filters at once and perform the search action)Customization available by recompiling the js app providedIT translations providedBaton is based on the following frontend technologies:Bootstrap 5FontAwesome 6Flexbox is used to accomplish responsiveness. jQuery is used for DOM manipulations.All JS, fonts and CSS are compiled, and produce a single JS file which is included in thebase_sitetemplate.A custom menu is provided, the menu is rendered through JS, and data is fetched in JSON format through an AJAX request.InstallationInstall the last stable release$ pip install django-batonor clone the repo inside your project$ git clone https://github.com/otto-torino/django-baton.gitAddbatonandbaton.autodiscoverto yourINSTALLED_APPS:INSTALLED_APPS=(# ...'baton','django.contrib.admin',# ... (place baton.autodiscover at the very end)'baton.autodiscover',)Replacedjango.contrib.adminin your project urls, and add baton urls:# from django.contrib import adminfrombaton.autodiscoverimportadminfromdjango.urlsimportpath,includeurlpatterns=[path('admin/',admin.site.urls),path('baton/',include('baton.urls')),]Why two installed apps?Well, firstbatonhas to be placed before thedjango.contrib.adminapp, because it overrides some templates and resets all CSS.
Thebaton.autodiscoverentry is needed as the last installed app in order to register all applications for the admin.
I decided to create a customAdminSiteclass, to allow the customization of some variables the Django way (site_header,index_title, ...). I think it's a good approach to customize these vars instead of overwriting the orignal templates. The problem is that when creating a custom AdminSite, you have to register all the apps manualy. I didn't like
that so I wrote thisautodiscovermodule which automatically registers all the apps registered with the Django's default AdminSite. For this to work, all the apps must be already registered so this app should be the last inINSTALLED_APPS.ConfigurationThe configuration dictionary must be defined inside your settings:BATON={'SITE_HEADER':'Baton','SITE_TITLE':'Baton','INDEX_TITLE':'Site administration','SUPPORT_HREF':'https://github.com/otto-torino/django-baton/issues','COPYRIGHT':'copyright © 2020 <a href="https://www.otto.to.it">Otto srl</a>',# noqa'POWERED_BY':'<a href="https://www.otto.to.it">Otto srl</a>','CONFIRM_UNSAVED_CHANGES':True,'SHOW_MULTIPART_UPLOADING':True,'ENABLE_IMAGES_PREVIEW':True,'CHANGELIST_FILTERS_IN_MODAL':True,'CHANGELIST_FILTERS_ALWAYS_OPEN':False,'CHANGELIST_FILTERS_FORM':True,'CHANGEFORM_FIXED_SUBMIT_ROW':True,'MENU_ALWAYS_COLLAPSED':False,'MENU_TITLE':'Menu','MESSAGES_TOASTS':False,'GRAVATAR_DEFAULT_IMG':'retro','GRAVATAR_ENABLED':True,'LOGIN_SPLASH':'/static/core/img/login-splash.png','FORCE_THEME':None,'SEARCH_FIELD':{'label':'Search contents...','url':'/search/',},'MENU':({'type':'title','label':'main','apps':('auth',)},{'type':'app','name':'auth','label':'Authentication','icon':'fa fa-lock','models':({'name':'user','label':'Users'},{'name':'group','label':'Groups'},)},{'type':'title','label':'Contents','apps':('flatpages',)},{'type':'model','label':'Pages','name':'flatpage','app':'flatpages'},{'type':'free','label':'Custom Link','url':'http://www.google.it','perms':('flatpages.add_flatpage','auth.change_user')},{'type':'free','label':'My parent voice','default_open':True,'children':[{'type':'model','label':'A Model','name':'mymodelname','app':'myapp'},{'type':'free','label':'Another custom link','url':'http://www.google.it'},]},)}SITE_HEADER,COPYRIGHTandPOWERED_BYare marked as safe, so you can include html (i.e. img tags and links).SUPPORT_HREFis the URL of the support link. For instance, you can usemailto:info@blabla.com.CONFIRM_UNSAVED_CHANGES: if set toTruea confirmation modal appears when leaving a change form or add form with unsaved changes.
The check of a dirty form relies on the jQuery serialize method, so it's not 100% safe. Disabled inputs, particular widgets (ckeditor) can not be detected.
Default value isTrue.SHOW_MULTIPART_UPLOADING: if set toTruean overlay with a spinner appears when submitting amultipart/form-dataform.ENABLE_IMAGES_PREVIEW: if set toTruea preview is displayed above all input file fields which contain images. You can control how the preview is displayed by overriding the class.baton-image-preview. By default, previews are 100px height and have a box shadow on "hover".CHANGELIST_FILTERS_IN_MODAL: if set toTruethe changelist filters are opened in a centered modal above the document, useful when you set many filters. By default, its value isFalseand the changelist filters appears from the right side of the changelist table.CHANGELIST_FILTERS_ALWAYS_OPEN: if set toTruethe changelist filters are opened by default. By default, its value isFalseand the changelist filters can be expanded clicking a toggle button. This option is considered only ifCHANGELIST_FILTERS_IN_MODALisFalse.CHANGELIST_FILTERS_FORM: if set toTruethe changelist filters are treated as in a form, you can set many of them and then press a filter button. With such option all standard filters are displayed as dropdowns.CHANGEFORM_FIXED_SUBMIT_ROW: if set toTruethe submit row in the changeform is fixed at the bottom on large screens.COLLAPSABLE_USER_AREA: if set toTruethe sidebar user area is collapsed and can be expanded to show links.MENU_ALWAYS_COLLAPSED: if set toTruethe menu is hidden at page load, and the navbar toggler is always visible, just click it to show the sidebar menu.MENU_TITLE: the menu title shown in the sidebar. If an empty string, the menu title is hidden and takes no space on larger screens, the default menu voice will still be visible in the mobile menu.MESSAGES_TOASTS: you can decide to show all or specific level admin messages in toasts. Set it toTrueto show all message in toasts. set it to['warning', 'error']to show only warning and error messages in toasts.GRAVATAR_DEFAULT_IMG: the default gravatar image displayed if the user email is not associated to any gravatar image. Possible values: 404, mp, identicon, monsterid, wavatar, retro, robohash, blank (seehttp://en.gravatar.com/site/implement/images/).GRAVATAR_ENABLED: should a gravatar image be shown for the user in the menu? Defaults toTrue.LOGIN_SPLASH: an image used as body background in the login page. The image is centered and covers the whole viewport.FORCE_THEME: You can force the light or dark theme, and the theme toggle disappears from the user area. Defaults toNoneMENUandSEARCH_FIELDconfigurations in detail:MENUCurrently four kind of items are supported:title,app,modelandfree.Title and free voices can have children, which follow the following rules:children items' children are ignored (do not place an app voice as a child)Items with children (title, app, free) can specify adefault_openkey to expand the submenu by default.If you don't define a MENU key in the configuration dictionary, the default MENU is shown.TitleLikeMAINandCONTENTSin the screenshot, it represents a menu section. You should set a label and optionally apps or perms key, used for visualization purposes.If the title voice should act as a section title for a group of apps, you'd want to specify these apps, because if the user can't operate over them, then the voice is not shown.
You can also define some perms (OR condition), like this:{ 'type': 'title', 'label': 'main', 'perms': ('auth.add_user', ) },Title items can have children and so you can specify thedefault_openkey.AppYou must specify thetypeandnamekeys. Optionally, aniconkey (you can use FontAwesome classes which are included by default), adefault_openkey and amodelskey.
If you don't define themodelskey, the default app models are listed under your app.NOTE:app name should be lowercaseModelYou must specify thetype,nameandappkeys. Optionally, an icon key.NOTE:model name should be lowercaseFreeYou can specify free items. You must define aurland if you want some visibility permissions (OR clause). Free items can have children and so you can specify thedefault_openkey. Free items also accept areproperty, which specifies a regular expression used to decide whether to highlight the voice or not (the regular expression is evaluated against the document location pathname).{
    'type': 'free',
    'label': 'Categories',
    'url': '/admin/news/category/',
    're': '^/admin/news/category/(\d*)?'
}SEARCH FIELDWith Baton you can optionally configure a search field in the sidebar above the menu.With this functionality, you can configure a sidebar input search field with autocomplete functionality that can let you surf easily and quickly to any page you desire.'SEARCH_FIELD': {
    'label': 'Label shown as placeholder',
    'url': '/api/path/',
},The autocomplete field will call a custom api at every keyup event. Such api receives thetextparam in the querystring and  should return a json response including the search results in the form:{
    length: 2,
    data: [
        { label: 'My result #1', icon: 'fa fa-edit', url: '/admin/myapp/mymodel/1/change' },
        // ...
    ]
}You should provide the results length and the data as an array of objects which must contain thelabelandurlkeys. Theiconkey is optional and is treated as css class given to anielement.Let's see an example:@staff_member_required
def admin_search(request):
    text = request.GET.get('text', None)
    res = []
    news = News.objects.all()
    if text:
        news = news.filter(title__icontains=text)
    for n in news:
        res.append({
            'label': str(n) + ' edit',
            'url': '/admin/news/news/%d/change' % n.id,
            'icon': 'fa fa-edit',
        })
    if text.lower() in 'Lucio Dalla Wikipedia'.lower():
        res.append({
            'label': 'Lucio Dalla Wikipedia',
            'url': 'https://www.google.com',
            'icon': 'fab fa-wikipedia-w'
        })
    return JsonResponse({
        'length': len(res),
        'data': res
    })You can move between the results using the keyboard up and down arrows, and you can browse to the voice url pressing Enter.Page DetectionBaton triggers some of its functionalities basing upon the current page. For example, it will trigger the tab functionality only when the current page is an add form or change form page.Baton understands which page is currently displayed performing some basic regular expressions against the location pathname.
There may be cases in which you'd like to serve such contents at different and custom urls, in such cases you need a way to tell Baton which kind of page is tied to that url.For this reason you can inject your custom hook, a javascript function which should return the page type and that receives as first argument the Baton's default function to use as fallback, i.e.<!-- admin/base_site.html -->{{ conf | json_script:"baton-config" }}<script>(function(){Baton.detectPageHook=fn=>/newschange/.test(location.pathname)?'change_form':fn()})()</script><scriptsrc="{% static 'baton/js_snippets/init_baton.js' %}"></script>In this case we tell Baton that when the location pathname includes the stringnewschange, then the page should be considered achange_form, otherwise we let Baton guess the page type.So, in order to hook into the Baton page detection system, just define aBaton.detectPageHookfunction which receives the default function as first argument and should return the page type.The available page types are the following:dashboard,admindocs,login,logout,passowrd_change,password_change_success,add_form,change_form,changelist,filer,default.SignalsBaton provides a dispatcher that can be used to register function that will be called when some events occurr.
Currently, Baton emits five types of events:onNavbarReady: dispatched when the navbar is fully renderedonMenuReady: dispatched when the menu is fully rendered (probably the last event fired, since the menu contents are retrieved async)onTabsReady: dispatched when the changeform tabs are fully renderedonMenuError: dispatched if the request sent to retrieve menu contents failsonReady: dispatched when Baton JS has finished its sync jobTo use these, just override the batonadmin/base_site.htmltemplate and register your listenersbeforecallingBaton.init, i.e.<!-- ... --><script>(function($,undefined){// init listenersBaton.Dispatcher.register('onReady',function(){console.log('BATON IS READY')})Baton.Dispatcher.register('onMenuReady',function(){console.log('BATON MENU IS READY')})Baton.Dispatcher.register('onNavbarReady',function(){console.log('BATON NAVBAR IS READY')})// end listeners})(jQuery,undefined)</script><scriptsrc="{% static 'baton/js_snippets/init_baton.js' %}"></script><!-- ... -->Js UtilitiesBaton comes with a number of exported js modules you can use to enhance your admin application.DispatcherBaton Dispatcher singleton module lets you subscribe to events and dispatch them, making use of the Mediator pattern.Example:// register a callback tied to the eventBaton.Dispatcher.register('myAppLoaded',function(evtName,s){console.log('COOL '+s)})// emit the eventBaton.Dispatcher.emit('myAppLoaded','STUFF!')ModalBaton Modal class lets you insert some content on a bootstrap modal without dealing with all the markup.Usage:// modal configuration://// let config = {//     title: 'My modal title',//     subtitle: 'My subtitle', // optional//     content: '<p>my html content</p>', // alternative to url//     url: '/my/url', // url used to perform an ajax request, the response is put inside the modal body. Alternative to content.//     hideFooter: false, // optional//     showBackBtn: false, // show a back button near the close icon, optional//     backBtnCb: function () {}, // back button click callback (useful to have a multi step modal), optional//     actionBtnLabel: 'save', // action button label, default 'save', optional//     actionBtnCb: null, // action button callback, optional//     onUrlLoaded: function () {}, // callback called when the ajax request has completed, optional//     size: 'lg', // modal size: sm, md, lg, xl, optional//     onClose: function () {} // callback called when the modal is closed, optional// }//// constructs a new modal instance// let myModal = new Baton.Modal(config)letmyModal=newBaton.Modal({title:'My modal title',content:'<p>my html content</p>',size:'lg'})myModal.open();myModal.close();myModal.update({title:'Step 2',content:'<p>cool</p>'})myModal.toggle();Js TranslationsThere are some circustamces in which Baton will print to screen some js message. Baton detects the user locale and will localize such messages, but it comes with justenandittranslations provided.Baton retrieves the current user locale from thelangattribute of thehtmltag.However you can provide or add your own translations by attaching an object to theBatonnamespace:// these are the default translations, you can just edit the one you need, or add some locales. Baton engine will always// pick up your custom translation first, if it finds them.// you can define the object before Baton.init in the base_site templateBaton.translations={unsavedChangesAlert:'You have some unsaved changes.',uploading:'Uploading...',filter:'Filter',close:'Close',save:'Save',search:'Search',cannotCopyToClipboardMessage:'Cannot copy to clipboard, please do it manually: Ctrl+C, Enter',retrieveDataError:'There was an error retrieving the data',lightTheme:'Light theme',darkTheme:'Dark theme'}If Baton can't find the translations for the user locale, it will default toen. Keep in mind that Baton will useentranslations for allen-xxlocales, but of course you can specify your custom translations!List FiltersInput Text FiltersTaken from thismedium articleBaton defines a custom InputFilter class that you can use to create text input filters and use them as any otherlist_filters, for example:# your app adminfrombaton.adminimportInputFilterclassIdFilter(InputFilter):parameter_name='id'title='id'defqueryset(self,request,queryset):ifself.value()isnotNone:search_term=self.value()returnqueryset.filter(id=search_term)classMyModelAdmin(admin.ModelAdmin):list_filters=('my_field',IdFilter,'my_other_field',)Dropdown FiltersTaken from the github appdjango-admin-list-filter-dropdownBaton provides a dropdown form of the following list filters:Django admin filter nameBaton nameSimpleListFilterSimpleDropdownFilterAllValuesFieldListFilterDropdownFilterChoicesFieldListFilterChoicesDropdownFilterRelatedFieldListFilterRelatedDropdownFilterRelatedOnlyFieldListFilterRelatedOnlyDropdownFilterThe dropdown is visible only if the filter contains at least three options, otherwise the default template is used.Usage:from baton.admin import DropdownFilter, RelatedDropdownFilter, ChoicesDropdownFilter

class MyModelAdmin(admin.ModelAdmin):
    # ...
    list_filter = (
        # for ordinary fields
        ('a_charfield', DropdownFilter),
        # for choice fields
        ('a_choicefield', ChoiceDropdownFilter),
        # for related fields
        ('a_foreignkey_field', RelatedDropdownFilter),
    )Multiple choice FiltersBaton defines a custom MultipleChoiceListFilter class that you can use to filter on multiple options, for example:# your app adminfrombaton.adminimportMultipleChoiceListFilterclassStatusListFilter(MultipleChoiceListFilter):title='Status'parameter_name='status__in'deflookups(self,request,model_admin):returnNews.Status.choicesclassMyModelAdmin(admin.ModelAdmin):list_filters=('my_field',StatusListFilter,'my_other_field',)Changelist IncludesIn order for this feature to work, the user browser must support html template tags.Baton lets you include templates directly inside the change list page, in any position you desire. It's as simple as specifying the template path and the position of the template:@admin.register(News)classNewsAdmin(admin.ModelAdmin):#...baton_cl_includes=[('news/admin_include_top.html','top',),('news/admin_include_below.html','below',)]In this case, Baton will place the content of theadmin_include_top.htmltemplate at the top of the changelist section (above the search field), and the content of theadmin_include_below.htmlbelow the changelist form.You can specify the following positions:PositionDescriptiontopthe template is placed inside the changelist form, at the topbottomthe template is placed inside the changelist form, at the bottomabovethe template is placed above the changelist formbelowthe template is placed below the changelist formAnd, of course, you can access the all the changelist view context variables inside your template.Changelist Filters IncludesIn order for this feature to work, the user browser must support html template tags.Baton lets you include templates directly inside the change list filter container, at the top or the bottom. It's as simple as specifying the template path and the position of the template:@admin.register(News)classNewsAdmin(admin.ModelAdmin):#...baton_cl_filters_includes=[('news/admin_filters_include_top.html','top',),('news/admin_filters_include_bottom.html','bottom',)]You can specify the following positions:PositionDescriptiontopthe template is placed inside the changelist filter container, at the topbottomthe template is placed inside the changelist filter container, at the bottomAnd, of course, you can access the all the changelist view context variables inside your template.Changelist Row AttributesIn order for this feature to work, the user browser must support html template tags.With Baton you can add every kind of html attribute (including css classes) to any element in the changelist table (cell, rows, ...)It's a bit tricky, let's see how:Add abaton_cl_rows_attributesfunction to yourModelAdminclass, which takesrequestandcl(changelist view) as parameters.Return a json dictionary where the keys are used to match an element and the values specifies the attributes and other rules to select the element.Better to see an example:classNewsModelAdmin(admin.ModelAdmin):#...defget_category(self,instance):returnmark_safe('<span class="span-category-id-%d">%s</span>'%(instance.id,str(instance.category)))get_category.short_description='category'defbaton_cl_rows_attributes(self,request,cl):data={}fornewsincl.queryset.filter(category__id=2):data[news.id]={'class':'table-info',}data[news.id]={'class':'table-success','data-lol':'lol','title':'A fantasctic tooltip!','selector':'.span-category-id-%d'%1,'getParent':'td',}returnjson.dumps(data)In such case we're returning a dictionary with possibly many keys (each key is an id of a news instance).The first kind of dictionary elements will add atable-infoclass to thetr(rows) containing the news respecting the rulecategory__id=2The second kind of element instead uses some more options to customize the element selection: you can specify a css selector, and you can specify if Baton should then take one of its parents, and in such case you can give a parent selector also.
In the example provided Baton will add the classtable-success,data-attributeand thetitleattribute to the cell which contains the element.span-category-id-1.So these are the rules:the defaultselectoris#result_list tr input[name=_selected_action][value=' + key + '], meaning that it can work only if the model is editable (you have the checkox inputs for selecting a row), and selects the row of the instance identified bykey. If you use a custom selector the dictionarykeyis unuseful.the defaultgetParentistr. You can change it at you will, or set it toFalse, in such case the element to which apply the given attributes will be the one specified byselector.Every other key different fromselectorandgetParentwill be considered an attribute and added to the element.Form tabsHow much I loved django-suit form tabs? Too much. So, this was a feature I couldn't live without.There are three types of tabs:fieldset tab: a tab containing a fieldsetinline tab: a tab containing an inlinegroup tab: a tab which can contain fieldsets and inlines in the order you specifyTabs' titles are retrieved automatically. For fieldset and inline tabs, it's the fieldset's title and the inline related verbose name plural.
For group tabs the first title is taken (either of an inline or fieldset section).Using group tabs you can mix inlines with fields just by splitting fields into fieldsets and arranging them in your preferred order.Let's see how to define tabs in your admin forms (everything is done through js, no templatetags or templates overriden):classAttributeInline(admin.StackedInline):model=Attributeextra=1classFeatureInline(admin.StackedInline):model=Featureextra=1classItemAdmin(admin.ModelAdmin):list_display=('label','description','main_feature',)inlines=[AttributeInline,FeatureInline,]fieldsets=(('Main',{'fields':('label',),'classes':('order-0','baton-tabs-init','baton-tab-inline-attribute','baton-tab-fs-content','baton-tab-group-fs-tech--inline-feature',),'description':'This is a description text'}),('Content',{'fields':('text',),'classes':('tab-fs-content',),'description':'This is another description text'}),('Tech',{'fields':('main_feature',),'classes':('tab-fs-tech',),'description':'This is another description text'}),)As you can see these are the rules:Inline classes remain the same, no action neededOn the first fieldset, define abaton-tabs-initclass which enables tabsOn the first fieldset, you can add anorder-[NUMBER]class, which will be used to determined in which position to place the first fieldset. The order starts from 0, and if omitted, the first fieldset has order 0. If you assign for example the classorder-2to the first fieldset, then the first fieldset will be the third tab, while all other tabs will respect the order of declaration.For every inline you want to put in a separate tab, add a classbaton-tab-inline-MODELNAMEorbaton-tab-inline-RELATEDNAMEif you've specified a related name in the model foreign key fieldFor every fieldset you want to put in a separate tab, add a classbaton-tab-fs-CUSTOMNAME, and add a classtab-fs-CUSTOMNAMEon the fieldsetFor every group you want to put in a separate tab, add a classbaton-tab-group-ITEMS, where items can be inlines (inline-RELATEDNAME) and/or fieldsets (fs-CUSTOMNAME) separated by a double hypen--. Also add a classtab-fs-CUSTOMNAMEon the fieldset items.Tabs order respects the defined classes orderFieldsets without a specified tab will be added to the main tab. If you want the fieldset to instead display outside of any tabs, add a classtab-fs-noneto the fieldset. The fieldset will then always be visible regardless of the current tab.Other features:When a field has an error, the first tab containing errors is opened automaticallyYou can open a tab on page load just by adding an hash to the url, i.e.#inline-feature,#fs-content,#group-fs-tech--inline-featureForm IncludesIn order for this feature to work, the user browser must support html template tags.Baton lets you include templates directly inside the change form page near a field, in any position you desire. It's as simple as specifying the template path, the field name used as anchor and the position of the template:@admin.register(News)classNewsAdmin(admin.ModelAdmin):#...baton_form_includes=[('news/admin_datetime_include.html','datetime','top',),('news/admin_content_include.html','content','above',)]In this case, Baton will place the content of theadmin_datetime_include.htmltemplate at the top of the datetime field row, and the content of theadmin_content_include.htmlabove the content field row.You can specify the following positions:PositionDescriptiontopthe template is placed inside the form row, at the topbottomthe template is placed inside the form row, at the bottomabovethe template is placed above the form rowbelowthe template is placed below the form rowrightthe template is placed inline at the input field right sideAnd, of course, you can access the{{ original }}object variable inside your template.It works seamlessly with the tab facility, if you include content related to a field inside one tab, then the content will be placed in the same tab.Baton lets also include templates in the object tools top bar in the change form page, keep in mind that suche templates are injected inside anultag. The template can be inserted on the left or the right:@admin.register(News)classNewsAdmin(admin.ModelAdmin):#...baton_form_object_tools_include=('news/object_tools_include.html','left',)Collapsable stacked inlines entriesBaton lets you collapse single stacked inline entries, just add acollapse-entryclass to the inline, with or without the entire collapse class:class VideosInline(admin.StackedInline):
    model = Video
    extra = 1
    classes = ('collapse-entry', )  # or ('collapse', 'collapse-entry', )And if you want the first entry to be initially expanded, add also theexpand-firstclass:class VideosInline(admin.StackedInline):
    model = Video
    extra = 1
    classes = ('collapse-entry', 'expand-first', )CustomizationIt's easy to heavily customize the appeareance ofbaton. All the stuff is compiled from a modern JS app which resides inbaton/static/baton/app.You just need to change theSASS variables values(and you can also overwrite Bootstrap variables), re-compile, get the compiled JS file, place it in the static folder of your main app,
and place your main app (ROOTAPP) beforebatonin theINSTALLED_APPS.So:$ git clone https://github.com/otto-torino/django-baton.git
$ cd django-baton/baton/static/baton/app/
$ npm install
$ vim src/styles/_variables.scss
$ npm run compile
$ cp dist/baton.min.js ROOTAPP/static/baton/app/dist/If you want to test your live changes, just start the webpack dev server:$ cd django-baton/baton/static/baton/app/
$ npm run devAnd inside thebase_site.htmltemplate, make these changes:<!-- <script src="{% static 'baton/app/dist/baton.min.js' %}"></script> comment the compiled src and uncomment the webpack served src -->
<script src="http://localhost:8080/static/baton/app/dist/baton.min.js"></script>Now while you make your changes to the JS app (CSS included), webpack will update the bundle automatically, so just refresh the page and you'll see your changes.TestsStarting from the release 1.7.1, django baton is provided with a set of unit and e2e tests. Testing baton is not so easy, because it almost do all the stuff with css rules and by manipulating the DOM. So the e2e tests are performed using selenium and inspecting the test application inside a real browser. In order to have them run properly, you need to have the test application running onlocalhost:8000.DevelopmentStart the test app (login admin:admin):$ cd testapp
$ python3 -m venv .virtualenv
$ cd app
$ pip install -r requirements.txt
$ python manage.py runserverSwitch the baton js path inbase_site.html<!-- <script src="{% static 'baton/app/dist/baton.min.js' %}"></script> comment the compiled src and uncomment the webpack served src -->
<script src="http://localhost:8080/static/baton/app/dist/baton.min.js"></script>Start the js app in watch mode$ cd baton/static/baton/app
$ npm install
$ npm run devNow you'll see live all your changes in the testapp.CommandsInstallinvokeandsphinx_rtd_theme$ pip install invoke sphinx_rtd_themeNow you can generate the documentation in order to check it. Inside the root dir:$ invoke docsContributingReadCONTRIBUTING.mdScreenshots