Create a Plugin#
This guide walks you through creating plugins in the FairDM Framework. Plugins are modular, reusable views that extend model detail pages with custom functionality.
Overview#
What is a Plugin?
A plugin is a Django class-based view (CBV) combined with the Plugin mixin that provides:
Automatic URL routing under model detail pages
Tab-based navigation (optional)
Permission-based access control
Template resolution hierarchy
Breadcrumb navigation
Static asset management
Key Benefits:
✅ No manual URL configuration required
✅ Automatic integration into model detail pages
✅ Reusable across multiple models
✅ Inheritable for creating base plugins in packages
✅ Polymorphic visibility with
checkfunctions
Quick Start#
1. Create a plugins.py Module#
In any Django app, create a file named plugins.py:
myapp/
├── templates/
│ └── plugins/
│ └── sample/
│ └── analysis.html
├── __init__.py
├── apps.py
├── models.py
├── plugins.py ← define plugins here
2. Import Required Components#
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView, UpdateView
from fairdm import plugins
from fairdm.contrib.plugins import Plugin
from .models import Sample
from .forms import SampleAnalysisForm
3. Define and Register a Basic Plugin#
@plugins.register(Sample)
class AnalysisPlugin(Plugin, TemplateView):
"""Display analysis results for a sample."""
# Tab configuration (required for tab visibility)
menu = {
"label": _("Analysis"),
"icon": "chart-bar",
"order": 20,
}
template_name = "plugins/sample/analysis.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Access the sample instance via self.object
context["analysis_data"] = self.object.get_analysis()
return context
4. Create a Template#
Templates follow a hierarchical resolution pattern. Create your template at:
templates/plugins/sample/analysis.html
{% extends "plugins/base.html" %}
{% block plugin_content %}
<div class="card">
<div class="card-header">
<h3>Analysis Results for {{ object.name }}</h3>
</div>
<div class="card-body">
<p>Analysis data: {{ analysis_data }}</p>
</div>
</div>
{% endblock %}
Plugin Configuration#
Tab Ordering#
Tabs are sorted by order value, then alphabetically by label:
Lower numbers appear first
Suggested ranges:
0-99: Overview and primary content
100-499: Edit/management functions
500-699: Secondary content (descriptions, metadata)
700-899: Actions (export, import)
900+: Dangerous actions (delete)
Permission-Based Access#
Require specific permissions for plugin access:
@plugins.register(Sample)
class EditPlugin(Plugin, UpdateView):
menu = {"label": _("Edit"), "icon": "pencil", "order": 100}
permission = "myapp.change_sample" # Django permission string
form_class = SampleForm
# Users without this permission:
# 1. Won't see the "Edit" tab
# 2. Get 403 Forbidden on direct URL access
Polymorphic Visibility#
Show plugins only for specific model subtypes:
from fairdm.contrib.plugins import is_instance_of
@plugins.register(Sample)
class RockAnalysisPlugin(Plugin, TemplateView):
check = is_instance_of(RockSample) # Only visible for RockSample
menu = {"label": _("Geochemistry"), "order": 30}
Custom visibility checks:
def has_location(request, obj):
"""Show plugin only if sample has a location."""
return obj and hasattr(obj, "location") and obj.location is not None
@plugins.register(Sample)
class LocationPlugin(Plugin, TemplateView):
check = has_location
menu = {"label": _("Location"), "order": 40}
Common Plugin Patterns#
1. Overview Plugin (Read-Only View)#
from fairdm.core.plugins import BaseOverviewPlugin
@plugins.register(Sample)
class SampleOverview(BaseOverviewPlugin):
# Inherits: menu = {"label": "Overview", "icon": "eye", "order": 0}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["measurements"] = self.object.measurements.all()
context["recent_activity"] = self.get_recent_activity()
return context
2. Edit Plugin (Form-Based)#
from fairdm.core.plugins import BaseEditPlugin
@plugins.register(Sample)
class SampleEdit(BaseEditPlugin):
menu = {"label": _("Edit"), "icon": "pencil", "order": 100}
form_class = SampleForm
permission = "samples.change_sample"
def get_success_url(self):
return self.object.get_absolute_url()
3. Delete Plugin#
from fairdm.core.plugins import BaseDeletePlugin
@plugins.register(Sample)
class SampleDelete(BaseDeletePlugin):
menu = {"label": _("Delete"), "icon": "trash", "order": 900}
permission = "samples.delete_sample"
def get_success_url(self):
from django.urls import reverse
return reverse("sample-list")
5. Action Plugin (Export, Download, etc.)#
from django.http import HttpResponse
@plugins.register(Sample)
class ExportCSV(Plugin):
menu = {"label": _("Export CSV"), "icon": "download", "order": 700}
def get(self, request, *args, **kwargs):
sample = self.object
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{sample.name}.csv"'
# Generate CSV content
writer = csv.writer(response)
writer.writerow(["Field", "Value"])
writer.writerow(["Name", sample.name])
# ... add more data
return response
Advanced Features#
Plugin Groups#
Wrap multiple plugins under a shared URL namespace and single tab:
from fairdm.contrib.plugins import PluginGroup
class MetadataView(Plugin, TemplateView):
menu = {"label": "View", "icon": "eye", "order": 10}
template_name = "plugins/metadata/view.html"
class MetadataEdit(Plugin, UpdateView):
menu = {"label": "Edit", "icon": "pencil", "order": 20}
form_class = MetadataForm
@plugins.register(Sample)
class MetadataGroup(PluginGroup):
plugins = [MetadataView, MetadataEdit]
menu = {"label": "Metadata", "icon": "database", "order": 50}
url_prefix = "metadata" # URLs: .../metadata/view/, .../metadata/edit/
Result:
Single “Metadata” tab in navigation
URLs namespaced under “metadata/”
Clicking tab navigates to first plugin (MetadataView)
Custom URL Paths#
Override auto-generated URL paths:
@plugins.register(Sample)
class AnalysisPlugin(Plugin, TemplateView):
url_path = "analyse" # Instead of auto-generated "analysis-plugin"
menu = {"label": "Analysis", "order": 20}
URL becomes: /samples/<uuid>/analyse/ instead of /samples/<uuid>/analysis-plugin/
Template Resolution Hierarchy#
Plugins automatically search for templates in this order:
Explicit
template_nameif setplugins/{model_name}/{plugin_name}.html(model-specific)plugins/{parent_model_name}/{plugin_name}.html(polymorphic models)plugins/{plugin_name}.html(plugin default)plugins/base.html(framework fallback)
Example: For AnalysisPlugin registered with Sample:
plugins/sample/analysis-plugin.htmlplugins/analysis-plugin.htmlplugins/base.html
This allows model-specific customization without code changes.
Static Assets (CSS/JavaScript)#
Include plugin-specific assets using Django’s Media class:
@plugins.register(Sample)
class ChartPlugin(Plugin, TemplateView):
menu = {"label": "Charts", "icon": "chart", "order": 40}
class Media:
css = {
"all": ("plugins/charts/styles.css",),
}
js = (
"https://cdn.jsdelivr.net/npm/chart.js",
"plugins/charts/init.js",
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["chart_data"] = self.object.get_chart_data()
return context
In your template:
{% extends "plugins/base.html" %}
{% block extra_head %}
{{ plugin_media.css }}
{% endblock %}
{% block plugin_content %}
<canvas id="myChart"></canvas>
{% endblock %}
{% block extra_js %}
{{ plugin_media.js }}
{% endblock %}
Creating Reusable Plugins#
Base Plugin Classes for Distribution#
Create base plugins that others can inherit:
# In your package: mypackage/plugins.py
from fairdm.contrib.plugins import Plugin
from django.views.generic import TemplateView
class GeologyAnalysisPlugin(Plugin, TemplateView):
"""Reusable geology analysis plugin."""
menu = {"label": "Geology", "icon": "gem", "order": 30}
template_name = "geology/analysis.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["geology_data"] = self.analyze_geology(self.object)
return context
def analyze_geology(self, sample):
"""Override this method to customize analysis."""
return {}
Portal Developer Inheritance#
Users of your package can inherit and customize:
# In portal project: myportal/plugins.py
from mypackage.plugins import GeologyAnalysisPlugin
from fairdm import plugins
from .models import Sample
@plugins.register(Sample)
class CustomGeologyPlugin(GeologyAnalysisPlugin):
# Inherit menu, template, base behavior
# Customize analysis logic
def analyze_geology(self, sample):
# Portal-specific geology analysis
return {
"custom_metric": sample.calculate_custom_metric(),
"local_data": sample.get_local_geology_data(),
}
Context Variables#
Plugins automatically receive these context variables:
object: The model instance (Project, Dataset, Sample, etc.)tabs: List of Tab objects for this modelbreadcrumbs: Breadcrumb navigation chainplugin_media: CSS/JS assets (if Media class defined)view: The plugin view instance
Accessing the Model Instance#
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Access the model instance
sample = self.object # or context["object"]
# Add custom data
context["measurements"] = sample.measurements.all()
context["location"] = sample.location
return context
Template Guidelines#
Extend the Base Template#
{% extends "plugins/base.html" %}
{% block plugin_content %}
<!-- Your content here -->
{% endblock %}
Available Template Blocks#
plugin_content: Main content area (recommended)
content (CSS links, etc.)extra_head: Additionalextra_js: Additional JavaScript at page bottombreadcrumbs: Override breadcrumb navigation
Use Framework Components#
{% load cotton %}
<c-card>
<c-card-header>
<h3>{{ object.name }}</h3>
</c-card-header>
<c-card-body>
<p>Your content here...</p>
</c-card-body>
</c-card>
Registering for Multiple Models#
Register a plugin with multiple models:
@plugins.register(Sample, Measurement)
class ExportPlugin(Plugin):
menu = {"label": "Export", "icon": "download", "order": 700}
def get(self, request, *args, **kwargs):
obj = self.object
# Works for both Sample and Measurement
return self.generate_export(obj)
Django System Checks#
The plugin system includes validation checks:
E001: Plugin missing required attributes (e.g., inherits neither Plugin nor Django CBV)
E002: Duplicate plugin names for the same model
E003: URL path conflicts between plugins
E004: Invalid template_name (file doesn’t exist)
E005: PluginGroup with empty plugins list
E006: PluginGroup contains invalid plugin classes
E007: URL prefix conflicts between plugin groups
W001: Invalid permission string (permission doesn’t exist)
W002: Menu configuration missing required keys
W003: URL path contains invalid characters
Run checks:
poetry run python manage.py check
Migration from Old Plugin API#
If you have plugins using the old API (@plugin.register('model.Model', category=...)), update them:
Old API:
@plugin.register('sample.Sample', category=plugins.EXPLORE)
class Analysis(BasePlugin, TemplateView):
menu_item = MenuLink(name="Analysis", icon="chart")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
sample = self.base_object
return context
New API:
@plugins.register(Sample)
class Analysis(Plugin, TemplateView):
menu = {"label": "Analysis", "icon": "chart", "order": 20}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
sample = self.object
return context
Key Changes:
✅ Use model class instead of string:
Samplenot'sample.Sample'✅ Import from
fairdm.contrib.pluginsnotfairdm.plugins✅ Inherit
Pluginmixin, notBasePlugin✅ Use
menudict, notmenu_item = MenuLink(...)✅ Access instance via
self.object, notself.base_object✅ Order tabs with
menu["order"], not categories✅ No
categoryparameter needed
Examples#
Complete Plugin Example#
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from fairdm import plugins
from fairdm.contrib.plugins import Plugin, is_instance_of
from .models import Sample, RockSample
@plugins.register(Sample)
class GeochemistryPlugin(Plugin, TemplateView):
"""Display geochemical analysis for rock samples."""
# Only show for RockSample instances
check = is_instance_of(RockSample)
# Tab configuration
menu = {
"label": _("Geochemistry"),
"icon": "atom",
"order": 35,
}
# Require permission
permission = "samples.view_geochemistry"
# Template
template_name = "plugins/sample/geochemistry.html"
# Static assets
class Media:
css = {"all": ("plugins/geochemistry/style.css",)}
js = ("plugins/geochemistry/charts.js",)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Get the rock sample
rock = self.object
# Add analysis data
context["elements"] = rock.get_element_concentrations()
context["isotopes"] = rock.get_isotope_ratios()
context["minerals"] = rock.get_mineral_composition()
return context
Best Practices#
✅ Always inherit from both
Pluginand a Django CBVclass MyPlugin(Plugin, TemplateView): # Correct
✅ Use
self.objectto access the model instancesample = self.object # Correct
✅ Set appropriate
ordervalues for logical tab orderingmenu = {"label": "Overview", "order": 0} # First tab menu = {"label": "Edit", "order": 100} # Middle tab menu = {"label": "Delete", "order": 900} # Last tab
✅ Use permission strings for access control
permission = "myapp.change_sample"
✅ Create model-specific templates for customization
templates/plugins/sample/my-plugin.html templates/plugins/measurement/my-plugin.html
✅ Use Media class for static assets, not inline
<style>or<script>class Media: css = {"all": ("myapp/plugin.css",)} js = ("myapp/plugin.js",)
✅ Test plugins with Django system checks
poetry run python manage.py check
Troubleshooting#
Plugin doesn’t appear as tab:
Check that
menuis set (notNone)Verify user has required
permissionCheck that
checkfunction returnsTrue
403 Forbidden when accessing plugin:
Verify
permissionattribute is correctEnsure user has the required permission
Check object-level permissions (django-guardian)
Template not found:
Check
template_nameis correctVerify template exists in search path
Use absolute path or rely on hierarchical resolution
URL conflict:
Run
python manage.py checkfor error E003Set unique
url_pathfor each pluginUse PluginGroups to namespace related plugins
Assets not loading:
Run
python manage.py collectstaticCheck static file paths in Media class
Verify STATIC_URL in settings
Further Reading#
Plugin System Architecture
Quick Start Examples
Demo App Plugins - Working examples
Plugins are automatically discovered from any plugins.py file in installed Django apps. Ensure your app is in INSTALLED_APPS for plugins to be loaded.
Summary#
Use
@plugin.register('model.name', category=plugins.CATEGORY)for registrationCreate explicit
MenuLinkinstances for menu configurationAccess the related object via
self.base_objectChoose appropriate base classes:
Explore,Action, orManagementWrap template content with
<c-plugin>componentsUse FairDM components for consistent UI design
Always use separate CSS/JS files with the
Mediainner class - never inline styles or scripts