Skip to content

Commit c949844

Browse files
committed
add mass_update_process and MassUpdateSkipRecordError
1 parent cea6d73 commit c949844

File tree

12 files changed

+295
-138
lines changed

12 files changed

+295
-138
lines changed

CHANGES

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* add typing
77
* pep621
88
* move to uv/ruff
9-
9+
* add `mass_update_process` and `MassUpdateSkipRecordError`
1010

1111
## 2.3
1212

docs/src/actions/mass_update.md

+22
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,28 @@ To filter out some fields you need to set `UPDATE_ACTION_IGNORED_FIELDS` setting
3030
},
3131
}
3232

33+
## Prevent Record to be updated
34+
35+
To prevent record to be updated based on custom logic, it is possible to connect to `mass_update_process` and raise `MassUpdateSkipRecordError`
36+
37+
Es:
38+
```python
39+
from django.contrib.auth.models import User
40+
from django.dispatch import receiver
41+
from adminactions.signals import mass_update_process
42+
from adminactions.exceptions import MassUpdateSkipRecordError
43+
44+
45+
@receiver(mass_update_process, sender=User)
46+
def my_handler(sender, record: User, **kwargs):
47+
if record.is_superuser:
48+
raise MassUpdateSkipRecordError
49+
50+
```
51+
52+
53+
54+
3355
## Transform Operation
3456

3557
<!-- sax:version 0.0.4 -->

src/adminactions/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class ActionInterruptedError(Exception):
77

88
class FakeTransactionError(Exception):
99
pass
10+
11+
12+
class MassUpdateSkipRecordError(Exception):
13+
pass

src/adminactions/mass_update.py

+30-25
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@
3333

3434
from . import config
3535
from .compat import celery_present
36-
from .exceptions import ActionInterruptedError
36+
from .exceptions import ActionInterruptedError, MassUpdateSkipRecordError
3737
from .forms import GenericActionForm
3838
from .perms import get_permission_codename
39-
from .signals import adminaction_end, adminaction_requested, adminaction_start
39+
from .signals import adminaction_end, adminaction_requested, adminaction_start, mass_update_process
4040
from .utils import curry, get_field_by_name
4141

4242
if TYPE_CHECKING:
@@ -325,46 +325,50 @@ def fix_json(self) -> None:
325325
field.disabled = label not in self.data
326326

327327

328-
def mass_update_execute(
328+
def mass_update_execute( # noqa: C901
329329
queryset: QuerySet[Model],
330330
rules: dict[str, tuple[callable, Any]],
331331
validate: bool,
332332
clean: bool,
333333
user_pk: Any,
334334
request: HttpRequest | None = None,
335-
) -> tuple[int, list[str]]:
335+
) -> tuple[int, dict[str, Any]]:
336336
errors: dict[str, Any] = {}
337337
updated: int = 0
338338
opts = queryset.model._meta
339339
adminaction_start.send(sender=queryset.model, action="mass_update", request=request, queryset=queryset)
340-
try:
340+
try: # noqa: PLR1702
341341
with atomic():
342342
if not validate:
343343
values = {field_name: value for field_name, (func_name, value) in rules.items()}
344344
queryset.update(**values)
345345
else:
346346
for record in queryset:
347-
for field_name, (func_name, value) in rules.items():
348-
field = queryset.model._meta.get_field(field_name)
349-
if isinstance(field, FileField):
350-
file_field = getattr(record, field_name)
351-
file_field.save(value.name, File(value.file))
352-
else:
353-
func = OPERATIONS.get_function(func_name)
354-
if callable(func):
355-
old_value = getattr(record, field_name)
356-
setattr(record, field_name, func(old_value))
347+
try:
348+
mass_update_process.send(sender=queryset.model, record=record, request=request)
349+
for field_name, (func_name, value) in rules.items():
350+
field = queryset.model._meta.get_field(field_name)
351+
if isinstance(field, FileField):
352+
file_field = getattr(record, field_name)
353+
file_field.save(value.name, File(value.file))
357354
else:
358-
changed_attr = getattr(record, field_name, None)
359-
if changed_attr.__class__.__name__ == "ManyRelatedManager":
360-
changed_attr.set(value)
355+
func = OPERATIONS.get_function(func_name)
356+
if callable(func):
357+
old_value = getattr(record, field_name)
358+
setattr(record, field_name, func(old_value))
361359
else:
362-
setattr(record, field_name, value)
363-
364-
if clean:
365-
record.clean()
366-
record.save()
367-
updated += 1
360+
changed_attr = getattr(record, field_name, None)
361+
if changed_attr.__class__.__name__ == "ManyRelatedManager":
362+
changed_attr.set(value)
363+
else:
364+
setattr(record, field_name, value)
365+
366+
if clean:
367+
record.clean()
368+
record.save()
369+
updated += 1
370+
except MassUpdateSkipRecordError:
371+
pass
368372
adminaction_end.send(
369373
sender=queryset.model,
370374
action="mass_update",
@@ -402,7 +406,8 @@ def not_required(field: df.Field, **kwargs: Any) -> forms.Field:
402406

403407
def _get_sample() -> dict[str, list[tuple[Any, str] | dict[str, Any]]]:
404408
grouped = defaultdict(list)
405-
for f in mass_update_hints:
409+
for fname in mass_update_hints:
410+
f = modeladmin.model._meta.get_field(fname)
406411
if isinstance(f, ForeignKey):
407412
# Filter by queryset so we only get results without our
408413
# current resultset

src/adminactions/signals.py

+2
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
adminaction_requested = django.dispatch.Signal()
44
adminaction_start = django.dispatch.Signal()
55
adminaction_end = django.dispatch.Signal()
6+
7+
mass_update_process = django.dispatch.Signal()

tests/.coveragerc

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ omit =
88
# Regexes for lines to exclude from consideration
99
exclude_lines =
1010
pragma: no cover
11+
if TYPE_CHECKING:
1112

1213
ignore_errors = False
1314

tests/demo/admin.py

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
4+
from admin_extra_buttons.api import button
5+
from admin_extra_buttons.mixins import ExtraButtonsMixin
6+
from django.contrib.admin import ModelAdmin, site
7+
8+
from adminactions.helpers import AdminActionPermMixin
9+
from adminactions.mass_update import MassUpdateForm
10+
from demo.models import DemoModel, DemoOneToOne, DemoRelated, UserDetail
11+
12+
13+
#
14+
# class SubclassedImageField(models.ImageField):
15+
# pass
16+
#
17+
#
18+
# class DemoModel(models.Model):
19+
# char = models.CharField("Chäř", max_length=255)
20+
# integer = models.IntegerField()
21+
# logic = models.BooleanField(default=False)
22+
# # null_logic = models.NullBooleanField(default=None)
23+
# date = models.DateField()
24+
# datetime = models.DateTimeField()
25+
# time = models.TimeField()
26+
# decimal = models.DecimalField(max_digits=10, decimal_places=3)
27+
# email = models.EmailField()
28+
# # filepath = models.FilePathField(path=__file__)
29+
# float = models.FloatField()
30+
# bigint = models.BigIntegerField()
31+
# # ip = models.IPAddressField()
32+
# generic_ip = models.GenericIPAddressField()
33+
# url = models.URLField()
34+
# text = models.TextField()
35+
# uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
36+
#
37+
# unique = models.CharField(max_length=255, unique=True)
38+
# nullable = models.CharField(max_length=255, null=True)
39+
# blank = models.CharField(max_length=255, blank=True, null=True)
40+
# not_editable = models.CharField(max_length=255, editable=False, blank=True, null=True)
41+
# choices = models.IntegerField(choices=((1, "Choice 1"), (2, "Choice 2"), (3, "Choice 3")))
42+
#
43+
# image = models.ImageField(blank=True, null=True)
44+
# subclassed_image = SubclassedImageField(blank=True, null=True)
45+
#
46+
# m2m = models.ManyToManyField("self", blank=True)
47+
#
48+
# class Meta:
49+
# app_label = "demo"
50+
# ordering = ("-id",)
51+
#
52+
#
53+
# class UserDetail(models.Model):
54+
# user = models.ForeignKey(User, on_delete=models.CASCADE)
55+
# note = models.CharField(max_length=10, blank=True)
56+
#
57+
# class Meta:
58+
# app_label = "demo"
59+
#
60+
#
61+
# class DemoOneToOne(models.Model):
62+
# demo = models.OneToOneField(DemoModel, on_delete=models.CASCADE, related_name="onetoone")
63+
#
64+
# class Meta:
65+
# app_label = "demo"
66+
#
67+
#
68+
# class DemoRelated(models.Model):
69+
# demo = models.ForeignKey(DemoModel, on_delete=models.CASCADE, related_name="related", to_field="uuid")
70+
#
71+
# class Meta:
72+
# app_label = "demo"
73+
#
74+
75+
class UserDetailModelAdmin(ExtraButtonsMixin, ModelAdmin):
76+
list_display = [f.name for f in UserDetail._meta.fields]
77+
78+
79+
def export_one(_modeladmin, _request, queryset):
80+
from adminactions.api import export_as_csv
81+
82+
return export_as_csv(queryset, fields=["get_custom_field"], modeladmin=_modeladmin)
83+
84+
85+
class DemoModelAdmin(ExtraButtonsMixin, ModelAdmin):
86+
# list_display = ('char', 'integer', 'logic', 'null_logic',)
87+
list_display = [f.name for f in DemoModel._meta.fields]
88+
actions = (export_one,)
89+
mass_update_hints = ["char", "choices", "logic"]
90+
91+
@button()
92+
def import_fixture(self, request):
93+
from adminactions.helpers import import_fixture as _import_fixture
94+
95+
return _import_fixture(self, request)
96+
97+
def get_custom_field(self, instance) -> str:
98+
return f"model-attribute-{instance.pk}"
99+
100+
101+
class DemoOneToOneAdmin(ExtraButtonsMixin, AdminActionPermMixin, ModelAdmin):
102+
pass
103+
104+
105+
class DemoRelatedAdmin(ExtraButtonsMixin, AdminActionPermMixin, ModelAdmin):
106+
mass_update_hints = ["demo", "choices", "logic", "nested_choices"]
107+
108+
109+
class TestMassUpdateForm(MassUpdateForm):
110+
pass
111+
112+
113+
class DemoModelMassUpdateForm(MassUpdateForm):
114+
sort_fields = False
115+
116+
117+
site.register(DemoModel, DemoModelAdmin)
118+
site.register(DemoOneToOne, DemoOneToOneAdmin)
119+
site.register(DemoRelated, DemoRelatedAdmin)
120+
site.register(UserDetail, UserDetailModelAdmin)

tests/demo/fixtures/demoproject.json

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"unique": "unique 1",
2222
"email": "[email protected]",
2323
"choices": 2,
24+
"nested_choices": 2,
2425
"image": "second.png",
2526
"subclassed_image": false
2627
}
@@ -47,6 +48,7 @@
4748
"unique": "unique 2",
4849
"email": "[email protected]",
4950
"choices": 2,
51+
"nested_choices": 2,
5052
"image": "first.png",
5153
"subclassed_image": "subclassed_first.png"
5254
}
@@ -73,6 +75,7 @@
7375
"unique": "unique 3",
7476
"email": "[email protected]",
7577
"choices": 2,
78+
"nested_choices": 2,
7679
"image": null,
7780
"subclassed_image": "subclassed_second.png"
7881
}

0 commit comments

Comments
 (0)