This guide is for projects upgrading to the SBAdmin action URL contract where every URL-routed action receives:
action_function(request, modifier, object_id)The action URL shape is now always:
<view>/<action>/<modifier>/
<view>/<action>/<modifier>/<object_id>/modifier and object_id are separate values. Row and detail actions no longer pass
the row primary key through modifier.
- Every
@sbadmin_actionmethod must acceptrequest, modifier, object_id. - Row method actions receive
modifier="template"and the row pk inobject_id. - Detail, fieldset, inline, and row modal actions receive the current object pk in
object_id. - List and selection actions usually receive
object_id=None. - Dynamic-region actions keep their real modifier, such as
"add", and pass the edit object through the optionalobject_idsegment. MODIFIER_OBJECT_IDis no longer an action modifier sentinel.MODIFIER_OBJECT_IDis still valid inside direct URL strings that need row-pk replacement.
Run these searches in consumer projects:
rg -n "@sbadmin_action|def action_|action_modifier=MODIFIER_OBJECT_ID|MODIFIER_OBJECT_ID|SBAdminRowAction|SBAdminFormViewAction|RowActionModalView|ActionModalView"Then update these cases:
def action_x(self, request, modifier)todef action_x(self, request, modifier, object_id).- Any row/detail action that reads
modifieras a pk to readobject_id. - Any direct call to a base action method to pass
object_idtoo. - Any
action_modifier=MODIFIER_OBJECT_IDon method or modal actions to remove it. - Any positional
get_action_url("action", object_id)calls to useget_action_url("action", object_id=object_id).
Before, row method actions often treated modifier as the row pk:
def get_sbadmin_row_actions(self, request):
return [
SBAdminRowAction(
action_id=ACTION_IMPERSONATE_USER,
title=_("Impersonate"),
icon="Login",
view=self,
)
]
@sbadmin_action(permission="view")
def action_impersonate_user(self, request, modifier):
post_data = request.POST.copy()
post_data["user_pk"] = str(modifier)
request.POST = post_data
return AcquireUserView.as_view()(request)After, keep modifier for action state and read the row pk from object_id:
def get_sbadmin_row_actions(self, request):
return [
SBAdminRowAction(
action_id=ACTION_IMPERSONATE_USER,
title=_("Impersonate"),
icon="Login",
view=self,
)
]
@sbadmin_action(permission="view")
def action_impersonate_user(self, request, modifier, object_id):
post_data = request.POST.copy()
post_data["user_pk"] = str(object_id)
request.POST = post_data
return AcquireUserView.as_view()(request)The generated row URL changes from a pk-as-modifier form to:
/admin-view/action_impersonate_user/template/<row_pk>/
MODIFIER_OBJECT_ID can still be used inside a URL string that is materialized per
row.
Before:
SBAdminRowAction(
url=self.get_action_url(
ACTION_DOWNLOAD_LABEL,
object_id=MODIFIER_OBJECT_ID,
),
title=_("Download label"),
icon="Download",
)
@sbadmin_action
def action_download_label(self, request, modifier):
document = service.download_label(str(modifier), actor=actor)
...After:
SBAdminRowAction(
url=self.get_action_url(
ACTION_DOWNLOAD_LABEL,
object_id=MODIFIER_OBJECT_ID,
),
title=_("Download label"),
icon="Download",
)
@sbadmin_action
def action_download_label(self, request, modifier, object_id):
document = service.download_label(str(object_id), actor=actor)
...The placeholder is replaced in the URL string before rendering each row. It is not an action modifier.
Before, method and modal actions sometimes used action_modifier=MODIFIER_OBJECT_ID
to force pk routing:
SBAdminFormViewAction(
target_view=PackagePriceAdjustmentView,
title=_("Adjust price"),
view=self,
action_modifier=MODIFIER_OBJECT_ID,
open_in_modal=True,
)After, remove the modifier override:
SBAdminFormViewAction(
target_view=PackagePriceAdjustmentView,
title=_("Adjust price"),
view=self,
open_in_modal=True,
)RowActionModalView.get_object_id() now reads object_id, so row/detail modal views
continue to load the current object through the owning admin queryset.
Before, passing the object id as the second positional argument made it the modifier:
SBAdminCustomAction(
title=_("Approve"),
url=self.get_action_url("approve", object_id),
)
@sbadmin_action
def approve(self, request, pk):
obj = self.get_object(request, pk)
...After, pass the object id by keyword and keep the three-argument signature:
SBAdminCustomAction(
title=_("Approve"),
url=self.get_action_url("approve", object_id=object_id),
)
@sbadmin_action
def approve(self, request, modifier, object_id):
obj = self.get_object(request, object_id)
...List and bulk actions still use modifier for list selection state, config ids, or
special values such as "__all__". They usually ignore object_id.
Before:
@sbadmin_action
def action_bulk_delete(self, request, modifier):
return super().action_bulk_delete(request, modifier)After:
@sbadmin_action
def action_bulk_delete(self, request, modifier, object_id):
return super().action_bulk_delete(request, modifier, object_id)When overriding built-in SBAdmin action methods, add object_id and forward it to
base methods.
Before:
@sbadmin_action
def action_enter_reorder(self, request, modifier):
if not self.use_tree_ordering:
return super().action_enter_reorder(request, modifier)
self.activate_reorder(request)
return self.action_list(request, tabulator_definition=tabulator_definition)
@sbadmin_action
def action_list_json(self, request, modifier, page_size=None):
action = self.sbadmin_list_action_class(self, request, page_size=page_size)
return JsonResponse(data=action.get_json_data(), safe=False)After:
@sbadmin_action
def action_enter_reorder(self, request, modifier, object_id):
if not self.use_tree_ordering:
return super().action_enter_reorder(request, modifier, object_id)
self.activate_reorder(request)
return self.action_list(
request,
modifier,
object_id,
tabulator_definition=tabulator_definition,
)
@sbadmin_action
def action_list_json(self, request, modifier, object_id=None, page_size=None):
action = self.sbadmin_list_action_class(self, request, page_size=page_size)
return JsonResponse(data=action.get_json_data(), safe=False)If an action manually delegates to an ActionModalView, pass both route values.
Before:
@sbadmin_action
def fix_email_action(self, request, modifier):
return ChangeCustomerEmailActionView.as_view(view=self)(request, modifier)After:
@sbadmin_action
def fix_email_action(self, request, modifier, object_id):
return ChangeCustomerEmailActionView.as_view(view=self)(
request,
modifier=modifier,
object_id=object_id,
)Keep MODIFIER_OBJECT_ID only when it is a placeholder inside a URL string:
SBAdminRowAction(
url=f"/articles/{MODIFIER_OBJECT_ID}/audit/",
title=_("Audit"),
icon="History",
)Do not use it as action_modifier:
# Bad
SBAdminRowAction(
action_id="archive",
view=self,
action_modifier=MODIFIER_OBJECT_ID,
)
# Good
SBAdminRowAction(
action_id="archive",
view=self,
)If a consumer builds dynamic-region URLs manually, keep the modifier as the region
mode and pass the edit object through object_id.
Before:
/admin-view/sbadmin_dynamic_region/<object_pk>/
After:
/admin-view/sbadmin_dynamic_region/add/<object_pk>/
Add/create forms still use:
/admin-view/sbadmin_dynamic_region/add/
After migrating a consumer project, run route-level tests for:
- row method actions
- row modal actions
- detail and fieldset modal actions
- list and bulk actions
- direct URL row actions that use
MODIFIER_OBJECT_ID - dynamic-region add and edit endpoints
- any MCP action invocation paths, if the project exposes SBAdmin through MCP
Direct service or method tests are not enough. Hit the generated SBAdmin URLs so the route shape, action registration, permissions, and modal dispatch are all exercised.
This guide was written after checking representative action patterns in:
/home/vilo/projects/neoship-app/home/vilo/projects/smartshop_template
No files in those consumer projects need to be changed as part of the SBAdmin library change itself, but they are good examples of the migrations projects will need after upgrading.