Hooks Reference
This page is the canonical reference for PowerCRUD's main public override points. Use the guides for workflow examples and setup, and use this page when you want to answer practical questions such as "where do I override this?" or "which hook should I use for this behavior?".
This is a curated reference for the public hooks most downstream projects are expected to use. It does not try to inventory every internal extension point.
If you want step-by-step walkthroughs rather than contracts, start with the advanced guides:
Adopting persistence hooks
If your project already customizes PowerCRUD writes, use these hooks to move away from internal save-path overrides.
Migration guide:
- If you currently override
form_valid()only to control the write, move that write logic intopersist_single_object(). - If you currently override inline save internals only to control the write, move that write logic into the same
persist_single_object()hook. - If you currently override sync bulk internals such as
bulk_edit_process_post()or_perform_bulk_update()only to route updates through app code, move that write logic intopersist_bulk_update(). - If you currently customize async bulk update by patching worker code or forking
powercrud.tasks.bulk_update_task, move that write logic into aBulkUpdatePersistenceBackendand configurebulk_update_persistence_backend_path.
Keep the older override only when it is doing something broader than persistence, such as:
- changing validation rules
- changing request/response flow
- changing template context or UI behavior
Upgrade notes:
persist_single_object()covers normal create/update forms and inline row update, but not delete flows.persist_bulk_update()is the sync bulk-update hook only. Bulk delete remains separate.BulkUpdatePersistenceBackendis the async bulk-update hook for writes. It is not a general-purposeAsyncManagersave hook.- When
bulk_update_persistence_backend_pathis configured, the default sync bulk-update path also uses that same backend so sync and async bulk update can share one write path. - Live CRUD view instances are not passed into async workers.
- Built-in single delete now redisplays cleanly when
model.delete()raisesValidationError, but delete still has no persistence hook equivalent topersist_single_object().
View lifecycle and queryset hooks
get_queryset()
- Purpose: Use this when you want to change which records the view works with in the first place, such as scoping rows to the current user, tenant, or workflow state.
- When it is called: During list rendering and any flow that depends on the view queryset.
- Signature:
def get_queryset(self) - Default behavior: Calls the parent
get_queryset()first, so the usual Neapolitan behavior still applies. That means an explicit classquerysetis still respected, otherwise PowerCRUD falls back tomodel._default_manager.all(). PowerCRUD then applies sort handling and adds a stable secondarypksort. - Return contract: A queryset for the current view.
- Important note: If you override this, call
super().get_queryset()unless you intentionally want to replace PowerCRUD's default sort behavior too. - Related docs: Setup & Core CRUD basics, Customisation tips
get_context_data()
- Purpose: Use this only when the template needs extra data that does not already come from PowerCRUD, such as a help panel, extra summary values, or feature flags for custom UI.
- When it is called: During template rendering for the list, detail, and form flows.
- Signature:
def get_context_data(self, **kwargs) - Default behavior: Extends the parent context; other PowerCRUD mixins also add their own context here, such as
form_display_itemsand bulk-selection metadata. - Return contract: A
dictfor template rendering. - Important note: If you are not customizing templates or adding custom page data, you can ignore this hook.
-
Short example:
-
Related docs: Forms, Customisation tips
get_list_cell_tooltip()
- Purpose: Use this when selected rendered list fields or properties should show semantic tooltip text that comes from the current row, for example expanded status context, related metadata, or a friendlier explanation behind an icon/badge cell.
- When it is called: During list-row rendering, but only for rendered fields/properties named in
list_cell_tooltip_fields. - Signature:
def get_list_cell_tooltip(self, obj, field_name, *, is_property, request=None) - Default behavior: Returns
None, so no semantic list-cell tooltip is shown. - Key arguments:
obj: The current row object.field_name: The rendered field or property name for the current cell.is_property:Truewhen the cell comes fromproperties, otherwiseFalse.request: The current request when available.
- Return contract: A plain string tooltip, or
Nonewhen no semantic tooltip should be shown for that cell. - Important note: PowerCRUD only calls this hook for configured names that are actually rendered in the list. Configured names not present in the current list are ignored silently.
- Important note: Semantic list-cell tooltips take precedence over the fallback overflow tooltip for the same cell, but existing blocked-inline tooltip states still win when the row is not editable inline.
- Important note: Returned text may contain newline characters. PowerCRUD preserves those as multiple displayed lines for hook-backed semantic list-cell tooltips only.
-
Short example:
list_cell_tooltip_fields = ["status", "has_invoice"] def get_list_cell_tooltip(self, obj, field_name, *, is_property, request=None): if field_name == "status": return obj.status_explanation if field_name == "has_invoice": return "Invoice attached" if obj.has_invoice else "Invoice missing" return None -
Related docs: Setup & Core CRUD basics, Sample app overview
get_filter_queryset_for_field()
- Purpose: Use this when a filter-form dropdown should not show every possible related record, for example when you only want active owners, visible categories, or tenant-scoped choices.
- When it is called: While building the filter form dropdown choices for related fields in
filterset_fields. - Signature:
def get_filter_queryset_for_field(self, field_name, model_field) - Default behavior: Starts from the related model queryset, applies any configured filter rules, then applies dropdown ordering.
- Return contract: A queryset of allowed choices for the filter field.
- Important note: This affects filter UI dropdowns, not edit-form dropdowns and not bulk-edit dropdowns.
-
Short example:
-
Related docs: Forms, Customisation tips
get_field_queryset_dependencies()
- Purpose: Use this when you want to refine, filter, or inspect the declarative
field_queryset_dependenciesmetadata before PowerCRUD applies it to forms or derives inline dependency wiring. - When it is called: While finalizing regular forms and inline forms, and while deriving inline dependent-field metadata.
- Signature:
def get_field_queryset_dependencies(self, *, available_fields=None, warn_on_unavailable=True) - Default behavior: Normalizes the configured
field_queryset_dependencies, drops unavailable child/parent fields, validatesfilter_bymappings, and preserves only usable static/dynamic rules. - Return contract: A dict keyed by child field name, with normalized dependency metadata.
- Important note: This is the shared config path behind regular form queryset scoping and inline dependent-field refreshes. Static queryset rules from the same config are also reused by bulk edit dropdowns, but dynamic parent/child rules are not.
- Related docs: Forms, Configuration options
Persistence hooks
persist_single_object()
- Purpose: Use this when you want PowerCRUD to keep form validation and response handling, but you want the actual save to go through your own application service or domain write logic.
- When it is called: After validation succeeds for the standard form save path and the inline row save path.
- Signature:
def persist_single_object(self, *, form, mode, instance=None) - Key arguments:
form: The validated Django form.mode: Currently"form"or"inline".instance: The bound object reference supplied by the caller.
- Default behavior: Calls
form.save(). - Return contract: The saved model instance. PowerCRUD stores it on
self.object. - Important note: If your override bypasses
form.save(), your override owns any requiredform.save_m2m()handling. -
Short example:
-
Related docs: Forms, Inline editing, Customisation tips
persist_bulk_update()
- Purpose: Use this when you want PowerCRUD to keep the bulk UI and normalized payload handling, but you want the actual multi-row update to go through your own bulk service or orchestration code.
- When it is called: After PowerCRUD has validated the bulk operation request and normalized the sync bulk payload.
- Signature:
def persist_bulk_update(self, *, queryset, fields_to_update, field_data, progress_callback=None) - Key arguments:
queryset: The objects selected for update.fields_to_update: The normalized list of chosen bulk-edit fields.field_data: The normalized field payload built from the request.progress_callback: Optional callback used by callers that want per-record progress updates.
- Default behavior: Delegates to PowerCRUD's standard sync bulk update implementation.
- Return contract: A result dict with
success,success_records, anderrors.success:Truewhen the bulk operation completed without handled errors.success_records: Count of updated rows on success. In the built-in transactional path this is0when validation fails, because the batch is rolled back.errors: A list of(label, messages)tuples.labelis a generic scope such as a field name or"general", andmessagesis a list of user-displayable strings.
- Important note: This is still the sync view hook. Async bulk update uses a worker-safe backend contract instead. If
bulk_update_persistence_backend_pathis configured and you do not override this hook yourself, the default sync implementation delegates to that same backend so sync and async can share one write path. - Important note: When
errorsis non-empty, PowerCRUD re-renders the bulk edit modal with those handled errors instead of treating the result as a server failure. -
Short example:
-
Handled validation-error example:
-
Related docs: Bulk editing (synchronous), Customisation tips
Bulk selection and bulk form hooks
get_bulk_choices_for_field()
- Purpose: Use this when a bulk-edit form dropdown should offer a narrower or more carefully ordered set of choices than the default related queryset.
- When it is called: While building the bulk-edit form dropdown choices for related fields.
- Signature:
def get_bulk_choices_for_field(self, field_name, field) - Default behavior: Starts from all related objects, applies declarative static queryset rules for the field, then applies
order_bymetadata fromfield_queryset_dependenciesor falls back to dropdown sort config. - Return contract: A queryset of choices, or
Nonewhen the field is not relation-backed. - Important note: Yes, this relates to
field_queryset_dependencies, but only partially. Bulk edit reusesstatic_filtersandorder_byfrom that config. It does not usedepends_onandfilter_by, because a bulk edit operation does not have one current parent form value to drive a child queryset. -
Short example:
-
Related docs: Bulk editing (synchronous), Customisation tips
get_bulk_selection_key_suffix()
- Purpose: Use this when PowerCRUD's default bulk selection storage is too broad and you want selections kept separate by user, tenant, tab, or another context value.
- When it is called: Whenever PowerCRUD reads or writes the session-backed bulk selection key.
- Signature:
def get_bulk_selection_key_suffix(self) -> str - Default behavior: Returns an empty string.
- Return contract: A string suffix appended to the session storage key.
-
Short example:
-
Related docs: Bulk editing (synchronous), Customisation tips
Standard action guard hooks
can_update_object()
- Purpose: Use this when some rows should keep the built-in Edit action visible but disabled, for example workflow-owned rows, canonical records, or objects that should stay read-only on a per-row basis.
- When it is called: During standard row-action rendering for the built-in Edit action, and while evaluating whether a row may enter inline edit mode.
- Signature:
def can_update_object(self, obj, request) - Default behavior: Returns
True, so built-in Edit and inline editing remain available unless a downstream override blocks the row. - Return contract: Truthy to allow row updates, falsy to disable the built-in Edit action and block inline editing for that row.
- Important note: This is the broad row-level update policy hook. It does not replace
inline_edit_fields, which still controls which fields are inline-editable on the screen, and it does not replaceinline_edit_allowed(), which remains available as an extra inline-only restriction. -
Short example:
-
Related docs: Inline editing hooks, Sample app overview
get_update_disabled_reason()
- Purpose: Use this with
can_update_object()when you want disabled Edit and inline affordances to explain why the row cannot be edited. - When it is called: During standard row-action rendering, and while preparing inline blocked-row affordances when
can_update_object()disables the row. - Signature:
def get_update_disabled_reason(self, obj, request) - Default behavior: Returns
None. - Return contract: A plain string tooltip, or
Nonewhen no explanation should be shown. - Important note: Existing lock-based action blocking still takes precedence when a row is already blocked by PowerCRUD's lock metadata.
-
Short example:
-
Related docs: Sample app overview
can_delete_object()
- Purpose: Use this when some rows should keep the built-in Delete action visible but disabled, for example canonical records, workflow-owned rows, or rows that should remain undeletable except to privileged users.
- When it is called: During standard row-action rendering for the built-in Delete action.
- Signature:
def can_delete_object(self, obj, request) - Default behavior: Returns
True, so the built-in Delete action remains enabled unless a downstream override blocks it. - Return contract: Truthy to allow the built-in Delete action, falsy to render it disabled.
- Important note: This controls the pre-click UI affordance only. It does not replace server-side protection. If
delete()still raisesValidationError, PowerCRUD's handled single-delete refusal flow remains the safety net. -
Short example:
-
Related docs: Customisation tips, Sample app overview
get_delete_disabled_reason()
- Purpose: Use this with
can_delete_object()when you want the disabled built-in Delete action to explain why the row cannot be deleted. - When it is called: During standard row-action rendering, only when the built-in Delete action is disabled by
can_delete_object(). - Signature:
def get_delete_disabled_reason(self, obj, request) - Default behavior: Returns
None. - Return contract: A plain string tooltip, or
Nonewhen no explanation should be shown. - Important note: Existing lock-based action blocking still takes precedence when a row is already blocked by PowerCRUD's lock metadata.
-
Short example:
-
Related docs: Sample app overview
Inline editing hooks
inline_edit_allowed
- Purpose: Use this when some rows should stay read-only inline even though inline editing is enabled for the view overall, for example archived or workflow-locked records.
- When it is called: During inline row rendering and again before accepting an inline save.
- Signature:
inline_edit_allowed(obj, request) - Default behavior: If unset, rows follow the standard inline permission and lock checks only.
- Return contract: Truthy to allow inline editing for that row, falsy to block it.
- Important note: This is an inline-only restriction layer. It does not replace
can_update_object(), which is the broader row-level update policy hook used by both the built-in Edit action and inline editing. -
Short example:
-
Related docs: Inline editing, Configuration options
is_inline_row_locked()
- Purpose: Use this when your project has custom lock rules and the default async-conflict check is not the whole story.
- When it is called: While evaluating whether a row can enter or remain in inline edit mode.
- Signature:
def is_inline_row_locked(self, obj) -> bool - Default behavior: Uses async conflict-checking helpers when available and returns
Falseotherwise. - Return contract:
Truewhen the row should be treated as locked. - Related docs: Inline editing, Async architecture & reference
get_inline_lock_details()
- Purpose: Use this when the UI needs richer information about why a row is locked, such as the task, user, or message associated with the lock.
- When it is called: When PowerCRUD builds row payloads or lock feedback for inline editing.
- Signature:
def get_inline_lock_details(self, obj) -> dict[str, Any] - Default behavior: Returns metadata derived from the async manager cache when available, otherwise an empty dict.
- Return contract: A dict describing the current lock state for the row.
- Related docs: Inline editing, Async architecture & reference
Inline row persistence itself does not use a separate hook. It routes through persist_single_object().
Inline dependent-field refreshes also do not use a separate inline-only hook. Declare the dependency once in field_queryset_dependencies; PowerCRUD applies that rule to regular forms and derives the inline dependency wiring automatically through get_field_queryset_dependencies().
Related docs:
Async hooks and extension points
async_task_lifecycle()
- Purpose: Use this when you want async tasks to trigger project-specific side effects such as dashboard rows, audit entries, notifications, or cleanup logic.
- When it is called: By the async manager and completion flow as task status changes.
- Signature:
def async_task_lifecycle(self, event, task_name, **kwargs) - Default behavior:
AsyncManagerprovides a no-op hook; downstream manager subclasses can persist dashboard rows, notify users, or record audit data. - Important note: This is the hook to call an external webhook or notification service if your project needs that. PowerCRUD does not ship webhook delivery, retry/backoff, dead-letter handling, or replay logic as built-in features.
- Return contract: No return value is required.
-
Short example:
-
Related docs: Async Manager, Async architecture & reference
BulkUpdatePersistenceBackend.persist_bulk_update()
- Purpose: Use this when async bulk update needs to go through app-level write orchestration without depending on a live CRUD view instance.
- When it is called: By the async bulk worker, and by the default sync bulk path as well when
bulk_update_persistence_backend_pathis configured. - Signature:
def persist_bulk_update(self, *, queryset, bulk_fields, fields_to_update, field_data, context, progress_callback=None) - Key arguments:
queryset: The objects selected for update.bulk_fields: The resolved allow-list of configured bulk-edit fields.fields_to_update: The field names requested for this operation.field_data: The normalized bulk payload built from the request.context: Plain execution context describing whether the operation is running in"sync"or"async"mode, plus task and user metadata where available.progress_callback: Optional callback for per-record progress updates.
- Default behavior:
DefaultBulkUpdatePersistenceBackendpreserves the built-in PowerCRUD bulk update implementation. - Return contract: A result dict with
success,success_records, anderrors. -
Short example:
class ProjectBulkUpdateBackend(BulkUpdatePersistenceBackend): def persist_bulk_update( self, *, queryset, bulk_fields, fields_to_update, field_data, context, progress_callback=None, ): return ProjectBulkUpdateService().apply( queryset=queryset, fields_to_update=fields_to_update, field_data=field_data, mode=context.mode, task_name=context.task_name, progress_callback=progress_callback, ) -
Related docs: Bulk editing (async), Async architecture & reference, Configuration options
resolve_bulk_update_persistence_backend()
- Purpose: Resolve the configured import path into a concrete bulk update persistence backend instance.
- When it is called: By PowerCRUD's sync bulk path and async bulk worker when backend configuration is present.
- Signature:
resolve_bulk_update_persistence_backend(backend_path, *, config=None) - Default behavior: Returns
DefaultBulkUpdatePersistenceBackendwhen no backend path is configured. - Related docs: Bulk editing (async), Async architecture & reference
AsyncManager.resolve_manager()
- Purpose: This lets workers and completion hooks find the same manager class your launch site configured, so custom lifecycle behavior stays consistent outside the request.
- When it is called: During async task execution and completion handling.
- Signature:
AsyncManager.resolve_manager(manager_class_path, config=None) - Default behavior: Resolves the configured manager class path and falls back to
AsyncManagerif resolution fails. - Related docs: Async Manager, Async architecture & reference
Keep the full async operational detail in the dedicated async docs: