# django-bulk-hooks
⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
`django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
## ✨ Features
- Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
- BEFORE/AFTER hooks for create, update, delete
- Hook-aware manager that wraps Django's `bulk_` operations
- **NEW**: `HookModelMixin` for individual model lifecycle events
- Hook chaining, hook deduplication, and atomicity
- Class-based hook handlers with DI support
- Support for both bulk and individual model operations
## 🚀 Quickstart
```bash
pip install django-bulk-hooks
```
### Define Your Model
```python
from django.db import models
from django_bulk_hooks.models import HookModelMixin
class Account(HookModelMixin):
balance = models.DecimalField(max_digits=10, decimal_places=2)
# The HookModelMixin automatically provides BulkHookManager
```
### Create a Hook Handler
```python
from django_bulk_hooks import hook, AFTER_UPDATE, Hook
from django_bulk_hooks.conditions import WhenFieldHasChanged
from .models import Account
class AccountHooks(Hook):
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
def log_balance_change(self, new_records, old_records):
print("Accounts updated:", [a.pk for a in new_records])
@hook(BEFORE_CREATE, model=Account)
def before_create(self, new_records, old_records):
for account in new_records:
if account.balance < 0:
raise ValueError("Account cannot have negative balance")
@hook(AFTER_DELETE, model=Account)
def after_delete(self, new_records, old_records):
print("Accounts deleted:", [a.pk for a in old_records])
```
## 🛠 Supported Hook Events
- `BEFORE_CREATE`, `AFTER_CREATE`
- `BEFORE_UPDATE`, `AFTER_UPDATE`
- `BEFORE_DELETE`, `AFTER_DELETE`
## 🔄 Lifecycle Events
### Individual Model Operations
The `HookModelMixin` automatically hooks hooks for individual model operations:
```python
# These will hook BEFORE_CREATE and AFTER_CREATE hooks
account = Account.objects.create(balance=100.00)
account.save() # for new instances
# These will hook BEFORE_UPDATE and AFTER_UPDATE hooks
account.balance = 200.00
account.save() # for existing instances
# This will hook BEFORE_DELETE and AFTER_DELETE hooks
account.delete()
```
### Bulk Operations
Bulk operations also hook the same hooks:
```python
# Bulk create - hooks BEFORE_CREATE and AFTER_CREATE hooks
accounts = [
Account(balance=100.00),
Account(balance=200.00),
]
Account.objects.bulk_create(accounts)
# Bulk update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
for account in accounts:
account.balance *= 1.1
Account.objects.bulk_update(accounts) # fields are auto-detected
# Bulk delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
Account.objects.bulk_delete(accounts)
```
### Queryset Operations
Queryset operations are also supported:
```python
# Queryset update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
Account.objects.update(balance=0.00)
# Queryset delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
Account.objects.delete()
```
### Subquery Support in Updates
When using `Subquery` objects in update operations, the computed values are automatically available in hooks. The system efficiently refreshes all instances in bulk for optimal performance:
```python
from django.db.models import Subquery, OuterRef, Sum
def aggregate_revenue_by_ids(self, ids: Iterable[int]) -> int:
return self.find_by_ids(ids).update(
revenue=Subquery(
FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
.filter(is_revenue=True)
.values("daily_financial_aggregate_id")
.annotate(revenue_sum=Sum("amount"))
.values("revenue_sum")[:1],
),
)
# In your hooks, you can now access the computed revenue value:
class FinancialAggregateHooks(Hook):
@hook(AFTER_UPDATE, model=DailyFinancialAggregate)
def log_revenue_update(self, new_records, old_records):
for new_record in new_records:
# This will now contain the computed value, not the Subquery object
print(f"Updated revenue: {new_record.revenue}")
# Bulk operations are optimized for performance:
def bulk_aggregate_revenue(self, ids: Iterable[int]) -> int:
# This will efficiently refresh all instances in a single query
return self.filter(id__in=ids).update(
revenue=Subquery(
FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
.filter(is_revenue=True)
.values("daily_financial_aggregate_id")
.annotate(revenue_sum=Sum("amount"))
.values("revenue_sum")[:1],
),
)
```
## 🧠 Why?
Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:
- Hooks that behave consistently across creates/updates/deletes
- **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
- Scalable performance via chunking (default 200)
- Support for `@hook` decorators and centralized hook classes
- **NEW**: Automatic hook hooking for admin operations and other Django features
- **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)
## 📦 Usage Examples
### Individual Model Operations
```python
# These automatically hook hooks
account = Account.objects.create(balance=100.00)
account.balance = 200.00
account.save()
account.delete()
```
### Bulk Operations
```python
# These also hook hooks
Account.objects.bulk_create(accounts)
Account.objects.bulk_update(accounts) # fields are auto-detected
Account.objects.bulk_delete(accounts)
```
### Advanced Hook Usage
```python
class AdvancedAccountHooks(Hook):
@hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
def validate_balance_change(self, new_records, old_records):
for new_account, old_account in zip(new_records, old_records):
if new_account.balance < 0 and old_account.balance >= 0:
raise ValueError("Cannot set negative balance")
@hook(AFTER_CREATE, model=Account)
def send_welcome_email(self, new_records, old_records):
for account in new_records:
# Send welcome email logic here
pass
```
### Salesforce-like Ordering Guarantees
The system ensures that `old_records` and `new_records` are always properly paired, regardless of the order in which you pass objects to bulk operations:
```python
class LoanAccountHooks(Hook):
@hook(BEFORE_UPDATE, model=LoanAccount)
def validate_account_number(self, new_records, old_records):
# old_records[i] always corresponds to new_records[i]
for new_account, old_account in zip(new_records, old_records):
if old_account.account_number != new_account.account_number:
raise ValidationError("Account number cannot be changed")
# This works correctly even with reordered objects:
accounts = [account1, account2, account3] # IDs: 1, 2, 3
reordered = [account3, account1, account2] # IDs: 3, 1, 2
# The hook will still receive properly paired old/new records
LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
```
## 🧩 Integration with Other Managers
### Recommended: QuerySet-based Composition (New Approach)
For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
```python
from django_bulk_hooks.queryset import HookQuerySet
from queryable_properties.managers import QueryablePropertiesManager
class MyManager(QueryablePropertiesManager):
"""Manager that combines queryable properties with hooks"""
def get_queryset(self):
# Get the QueryableProperties QuerySet
qs = super().get_queryset()
# Apply hooks on top of it
return HookQuerySet.with_hooks(qs)
class Article(models.Model):
title = models.CharField(max_length=100)
published = models.BooleanField(default=False)
objects = MyManager()
# This gives you both queryable properties AND hooks
# No inheritance conflicts, no MRO issues!
```
### Alternative: Explicit Hook Application
For more control, you can apply hooks explicitly:
```python
class MyManager(QueryablePropertiesManager):
def get_queryset(self):
return super().get_queryset()
def with_hooks(self):
"""Apply hooks to this queryset"""
return HookQuerySet.with_hooks(self.get_queryset())
# Usage:
Article.objects.with_hooks().filter(published=True).update(title="Updated")
```
### Legacy: Manager Inheritance (Not Recommended)
The old inheritance approach still works but is not recommended due to potential MRO conflicts:
```python
from django_bulk_hooks.manager import BulkHookManager
from queryable_properties.managers import QueryablePropertiesManager
class MyManager(BulkHookManager, QueryablePropertiesManager):
pass # ⚠️ Can cause inheritance conflicts
```
**Why the new approach is better:**
- ✅ No inheritance conflicts
- ✅ No MRO (Method Resolution Order) issues
- ✅ Works with any manager combination
- ✅ Cleaner and more maintainable
- ✅ Follows Django's queryset enhancement patterns
Framework needs to:
Register these methods
Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
Execute them in priority order
Pass ChangeSet to them
Handle errors (rollback on failure)
## 📝 License
MIT © 2024 Augend / Konrad Beck
Raw data
{
"_id": null,
"home_page": "https://github.com/AugendLimited/django-bulk-hooks",
"name": "django-bulk-hooks",
"maintainer": null,
"docs_url": null,
"requires_python": "<4.0,>=3.11",
"maintainer_email": null,
"keywords": "django, bulk, hooks",
"author": "Konrad Beck",
"author_email": "konrad.beck@merchantcapital.co.za",
"download_url": "https://files.pythonhosted.org/packages/d1/5e/997675bfc9637e03f2e99496f7a6524451bfad2a53e3e6f789257ab8adec/django_bulk_hooks-0.2.75.tar.gz",
"platform": null,
"description": "\n# django-bulk-hooks\n\n\u26a1 Bulk hooks for Django bulk operations and individual model lifecycle events.\n\n`django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` \u2014 including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.\n\n## \u2728 Features\n\n- Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`\n- BEFORE/AFTER hooks for create, update, delete\n- Hook-aware manager that wraps Django's `bulk_` operations\n- **NEW**: `HookModelMixin` for individual model lifecycle events\n- Hook chaining, hook deduplication, and atomicity\n- Class-based hook handlers with DI support\n- Support for both bulk and individual model operations\n\n## \ud83d\ude80 Quickstart\n\n```bash\npip install django-bulk-hooks\n```\n\n### Define Your Model\n\n```python\nfrom django.db import models\nfrom django_bulk_hooks.models import HookModelMixin\n\nclass Account(HookModelMixin):\n balance = models.DecimalField(max_digits=10, decimal_places=2)\n # The HookModelMixin automatically provides BulkHookManager\n```\n\n### Create a Hook Handler\n\n```python\nfrom django_bulk_hooks import hook, AFTER_UPDATE, Hook\nfrom django_bulk_hooks.conditions import WhenFieldHasChanged\nfrom .models import Account\n\nclass AccountHooks(Hook):\n @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged(\"balance\"))\n def log_balance_change(self, new_records, old_records):\n print(\"Accounts updated:\", [a.pk for a in new_records])\n \n @hook(BEFORE_CREATE, model=Account)\n def before_create(self, new_records, old_records):\n for account in new_records:\n if account.balance < 0:\n raise ValueError(\"Account cannot have negative balance\")\n \n @hook(AFTER_DELETE, model=Account)\n def after_delete(self, new_records, old_records):\n print(\"Accounts deleted:\", [a.pk for a in old_records])\n```\n\n## \ud83d\udee0 Supported Hook Events\n\n- `BEFORE_CREATE`, `AFTER_CREATE`\n- `BEFORE_UPDATE`, `AFTER_UPDATE`\n- `BEFORE_DELETE`, `AFTER_DELETE`\n\n## \ud83d\udd04 Lifecycle Events\n\n### Individual Model Operations\n\nThe `HookModelMixin` automatically hooks hooks for individual model operations:\n\n```python\n# These will hook BEFORE_CREATE and AFTER_CREATE hooks\naccount = Account.objects.create(balance=100.00)\naccount.save() # for new instances\n\n# These will hook BEFORE_UPDATE and AFTER_UPDATE hooks\naccount.balance = 200.00\naccount.save() # for existing instances\n\n# This will hook BEFORE_DELETE and AFTER_DELETE hooks\naccount.delete()\n```\n\n### Bulk Operations\n\nBulk operations also hook the same hooks:\n\n```python\n# Bulk create - hooks BEFORE_CREATE and AFTER_CREATE hooks\naccounts = [\n Account(balance=100.00),\n Account(balance=200.00),\n]\nAccount.objects.bulk_create(accounts)\n\n# Bulk update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks\nfor account in accounts:\n account.balance *= 1.1\nAccount.objects.bulk_update(accounts) # fields are auto-detected\n\n# Bulk delete - hooks BEFORE_DELETE and AFTER_DELETE hooks\nAccount.objects.bulk_delete(accounts)\n```\n\n### Queryset Operations\n\nQueryset operations are also supported:\n\n```python\n# Queryset update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks\nAccount.objects.update(balance=0.00)\n\n# Queryset delete - hooks BEFORE_DELETE and AFTER_DELETE hooks\nAccount.objects.delete()\n```\n\n### Subquery Support in Updates\n\nWhen using `Subquery` objects in update operations, the computed values are automatically available in hooks. The system efficiently refreshes all instances in bulk for optimal performance:\n\n```python\nfrom django.db.models import Subquery, OuterRef, Sum\n\ndef aggregate_revenue_by_ids(self, ids: Iterable[int]) -> int:\n return self.find_by_ids(ids).update(\n revenue=Subquery(\n FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef(\"pk\"))\n .filter(is_revenue=True)\n .values(\"daily_financial_aggregate_id\")\n .annotate(revenue_sum=Sum(\"amount\"))\n .values(\"revenue_sum\")[:1],\n ),\n )\n\n# In your hooks, you can now access the computed revenue value:\nclass FinancialAggregateHooks(Hook):\n @hook(AFTER_UPDATE, model=DailyFinancialAggregate)\n def log_revenue_update(self, new_records, old_records):\n for new_record in new_records:\n # This will now contain the computed value, not the Subquery object\n print(f\"Updated revenue: {new_record.revenue}\")\n\n# Bulk operations are optimized for performance:\ndef bulk_aggregate_revenue(self, ids: Iterable[int]) -> int:\n # This will efficiently refresh all instances in a single query\n return self.filter(id__in=ids).update(\n revenue=Subquery(\n FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef(\"pk\"))\n .filter(is_revenue=True)\n .values(\"daily_financial_aggregate_id\")\n .annotate(revenue_sum=Sum(\"amount\"))\n .values(\"revenue_sum\")[:1],\n ),\n )\n```\n\n## \ud83e\udde0 Why?\n\nDjango's `bulk_` methods bypass signals and `save()`. This package fills that gap with:\n\n- Hooks that behave consistently across creates/updates/deletes\n- **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`\n- Scalable performance via chunking (default 200)\n- Support for `@hook` decorators and centralized hook classes\n- **NEW**: Automatic hook hooking for admin operations and other Django features\n- **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)\n\n## \ud83d\udce6 Usage Examples\n\n### Individual Model Operations\n\n```python\n# These automatically hook hooks\naccount = Account.objects.create(balance=100.00)\naccount.balance = 200.00\naccount.save()\naccount.delete()\n```\n\n### Bulk Operations\n\n```python\n# These also hook hooks\nAccount.objects.bulk_create(accounts)\nAccount.objects.bulk_update(accounts) # fields are auto-detected\nAccount.objects.bulk_delete(accounts)\n```\n\n### Advanced Hook Usage\n\n```python\nclass AdvancedAccountHooks(Hook):\n @hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged(\"balance\"))\n def validate_balance_change(self, new_records, old_records):\n for new_account, old_account in zip(new_records, old_records):\n if new_account.balance < 0 and old_account.balance >= 0:\n raise ValueError(\"Cannot set negative balance\")\n \n @hook(AFTER_CREATE, model=Account)\n def send_welcome_email(self, new_records, old_records):\n for account in new_records:\n # Send welcome email logic here\n pass\n```\n\n### Salesforce-like Ordering Guarantees\n\nThe system ensures that `old_records` and `new_records` are always properly paired, regardless of the order in which you pass objects to bulk operations:\n\n```python\nclass LoanAccountHooks(Hook):\n @hook(BEFORE_UPDATE, model=LoanAccount)\n def validate_account_number(self, new_records, old_records):\n # old_records[i] always corresponds to new_records[i]\n for new_account, old_account in zip(new_records, old_records):\n if old_account.account_number != new_account.account_number:\n raise ValidationError(\"Account number cannot be changed\")\n\n# This works correctly even with reordered objects:\naccounts = [account1, account2, account3] # IDs: 1, 2, 3\nreordered = [account3, account1, account2] # IDs: 3, 1, 2\n\n# The hook will still receive properly paired old/new records\nLoanAccount.objects.bulk_update(reordered) # fields are auto-detected\n```\n\n## \ud83e\udde9 Integration with Other Managers\n\n### Recommended: QuerySet-based Composition (New Approach)\n\nFor the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:\n\n```python\nfrom django_bulk_hooks.queryset import HookQuerySet\nfrom queryable_properties.managers import QueryablePropertiesManager\n\nclass MyManager(QueryablePropertiesManager):\n \"\"\"Manager that combines queryable properties with hooks\"\"\"\n\n def get_queryset(self):\n # Get the QueryableProperties QuerySet\n qs = super().get_queryset()\n # Apply hooks on top of it\n return HookQuerySet.with_hooks(qs)\n\nclass Article(models.Model):\n title = models.CharField(max_length=100)\n published = models.BooleanField(default=False)\n\n objects = MyManager()\n\n# This gives you both queryable properties AND hooks\n# No inheritance conflicts, no MRO issues!\n```\n\n### Alternative: Explicit Hook Application\n\nFor more control, you can apply hooks explicitly:\n\n```python\nclass MyManager(QueryablePropertiesManager):\n def get_queryset(self):\n return super().get_queryset()\n\n def with_hooks(self):\n \"\"\"Apply hooks to this queryset\"\"\"\n return HookQuerySet.with_hooks(self.get_queryset())\n\n# Usage:\nArticle.objects.with_hooks().filter(published=True).update(title=\"Updated\")\n```\n\n### Legacy: Manager Inheritance (Not Recommended)\n\nThe old inheritance approach still works but is not recommended due to potential MRO conflicts:\n\n```python\nfrom django_bulk_hooks.manager import BulkHookManager\nfrom queryable_properties.managers import QueryablePropertiesManager\n\nclass MyManager(BulkHookManager, QueryablePropertiesManager):\n pass # \u26a0\ufe0f Can cause inheritance conflicts\n```\n\n**Why the new approach is better:**\n- \u2705 No inheritance conflicts\n- \u2705 No MRO (Method Resolution Order) issues\n- \u2705 Works with any manager combination\n- \u2705 Cleaner and more maintainable\n- \u2705 Follows Django's queryset enhancement patterns\n\nFramework needs to:\nRegister these methods\nKnow when to execute them (BEFORE_UPDATE, AFTER_UPDATE)\nExecute them in priority order\nPass ChangeSet to them\nHandle errors (rollback on failure)\n\n## \ud83d\udcdd License\n\nMIT \u00a9 2024 Augend / Konrad Beck\n",
"bugtrack_url": null,
"license": "MIT",
"summary": "Hook-style hooks for Django bulk operations like bulk_create and bulk_update.",
"version": "0.2.75",
"project_urls": {
"Homepage": "https://github.com/AugendLimited/django-bulk-hooks",
"Repository": "https://github.com/AugendLimited/django-bulk-hooks"
},
"split_keywords": [
"django",
" bulk",
" hooks"
],
"urls": [
{
"comment_text": "",
"digests": {
"blake2b_256": "611bc5040521e4c4d9b37eb629d4631f93aff4cb96017e6925f899551a8df483",
"md5": "874b7c271d992a9a6875be7d97d15286",
"sha256": "1767d7a742f047920be475fefa3c8054b467f122a70bb7de02603a6a8685400e"
},
"downloads": -1,
"filename": "django_bulk_hooks-0.2.75-py3-none-any.whl",
"has_sig": false,
"md5_digest": "874b7c271d992a9a6875be7d97d15286",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": "<4.0,>=3.11",
"size": 64575,
"upload_time": "2025-11-02T22:14:16",
"upload_time_iso_8601": "2025-11-02T22:14:16.914271Z",
"url": "https://files.pythonhosted.org/packages/61/1b/c5040521e4c4d9b37eb629d4631f93aff4cb96017e6925f899551a8df483/django_bulk_hooks-0.2.75-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": "",
"digests": {
"blake2b_256": "d15e997675bfc9637e03f2e99496f7a6524451bfad2a53e3e6f789257ab8adec",
"md5": "ca5278ff48a8e779f101394a38a2b11d",
"sha256": "f1182e30e09008f92e919b13b25e0d612cd8401e5fce569d57fe6e45342c269b"
},
"downloads": -1,
"filename": "django_bulk_hooks-0.2.75.tar.gz",
"has_sig": false,
"md5_digest": "ca5278ff48a8e779f101394a38a2b11d",
"packagetype": "sdist",
"python_version": "source",
"requires_python": "<4.0,>=3.11",
"size": 54653,
"upload_time": "2025-11-02T22:14:18",
"upload_time_iso_8601": "2025-11-02T22:14:18.780182Z",
"url": "https://files.pythonhosted.org/packages/d1/5e/997675bfc9637e03f2e99496f7a6524451bfad2a53e3e6f789257ab8adec/django_bulk_hooks-0.2.75.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2025-11-02 22:14:18",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "AugendLimited",
"github_project": "django-bulk-hooks",
"github_not_found": true,
"lcname": "django-bulk-hooks"
}