Skip to content

Commit 6a2e419

Browse files
authored
Merge pull request #7833 from specify/issue-7797
Delte blockers hot fix
2 parents 036a778 + 50152a0 commit 6a2e419

File tree

3 files changed

+194
-7
lines changed

3 files changed

+194
-7
lines changed

specifyweb/backend/delete_blockers/views.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from django import http
2-
from django.db import router
2+
from django.db import router, transaction
33
from django.db.models.deletion import Collector
44

55
from specifyweb.middleware.general import require_http_methods
6-
from specifyweb.specify.api.crud import get_object_or_404
6+
from specifyweb.specify.api.crud import (
7+
get_discipline_delete_guard_blockers,
8+
get_object_or_404,
9+
prepare_discipline_for_delete,
10+
)
711
from specifyweb.specify.api.serializers import toJson
812
from specifyweb.specify.views import login_maybe_required
913

@@ -16,10 +20,30 @@ def delete_blockers(request, model, id):
1620
"""
1721
obj = get_object_or_404(model, id=int(id))
1822
using = router.db_for_write(obj.__class__, instance=obj)
23+
24+
if obj._meta.model_name == 'discipline': # Special case for discipline
25+
if not request.specify_user.is_admin():
26+
return http.HttpResponseForbidden('Specifyuser must be an institution admin')
27+
guard_blockers = get_discipline_delete_guard_blockers(obj)
28+
if guard_blockers:
29+
result = guard_blockers
30+
else:
31+
# Try pre-delete discipline tree detaching
32+
with transaction.atomic(using=using):
33+
prepare_discipline_for_delete(obj)
34+
result = _collect_delete_blockers(obj, using)
35+
transaction.set_rollback(True, using=using)
36+
else:
37+
# Standard delete blockers behavior
38+
result = _collect_delete_blockers(obj, using)
39+
40+
return http.HttpResponse(toJson(result), content_type='application/json')
41+
42+
def _collect_delete_blockers(obj, using) -> list[dict]:
1943
collector = Collector(using=using)
2044
collector.delete_blockers = []
2145
collector.collect([obj])
22-
result = flatten([
46+
return flatten([
2347
[
2448
{
2549
'table': sub_objs[0].__class__.__name__,
@@ -28,7 +52,6 @@ def delete_blockers(request, model, id):
2852
}
2953
] for field, sub_objs in collector.delete_blockers
3054
])
31-
return http.HttpResponse(toJson(result), content_type='application/json')
3255

3356
def flatten(l):
3457
return [item for sublist in l for item in sublist]

specifyweb/backend/trees/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@
2828
'tectonicunit': 'Tectonic Unit'
2929
}
3030

31+
DISCIPLINE_TREE_MODELS = (
32+
spmodels.Geographytreedef,
33+
spmodels.Geologictimeperiodtreedef,
34+
spmodels.Lithostrattreedef,
35+
spmodels.Taxontreedef,
36+
spmodels.Tectonicunittreedef,
37+
)
38+
3139
def get_search_filters(collection: spmodels.Collection, tree: str):
3240
tree_name = tree.lower()
3341
if tree_name == 'storage':

specifyweb/specify/api/crud.py

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
from collections.abc import Callable
77
from django.db import transaction
88
from django.core.exceptions import FieldError, FieldDoesNotExist
9-
from django.db.models import Model, F
9+
from django.db.models import Model, F, Q, Subquery
1010
from django.http import (HttpResponseServerError, Http404)
1111
from django.apps import apps
1212

1313
from specifyweb.backend.permissions.permissions import check_field_permissions, check_table_permissions
14+
from specifyweb.backend.businessrules.exceptions import BusinessRuleException
15+
from specifyweb.backend.trees.utils import DISCIPLINE_TREE_MODELS
1416
from specifyweb.backend.workbench.upload.auditlog import auditlog
1517
from specifyweb.specify import models
1618
from specifyweb.specify.api.api_utils import objs_to_data_, CollectionPayload
@@ -188,6 +190,146 @@ def _deleter(obj, parent_obj):
188190
auditlog.remove(obj, agent, parent_obj)
189191
return _deleter
190192

193+
def is_discipline(obj) -> bool:
194+
return getattr(getattr(obj, "_meta", None), "model_name", "") == "discipline"
195+
196+
def get_discipline_delete_guard_blockers(discipline) -> list[dict[str, Any]]:
197+
"""
198+
Returns discipline blockers for deleting disciplines in the configuration tool.
199+
A discipline can be deleted only when it has no associated collections and no associated users.
200+
"""
201+
if not is_discipline(discipline):
202+
return []
203+
204+
collection_ids = sorted(discipline.collections.values_list("id", flat=True))
205+
206+
blockers: list[dict[str, Any]] = []
207+
if collection_ids:
208+
blockers.append(
209+
{
210+
"table": "Collection",
211+
"field": "discipline",
212+
"ids": collection_ids,
213+
}
214+
)
215+
return blockers
216+
217+
discipline_scoped_user_blockers = (
218+
(
219+
"Spappresourcedir",
220+
"specifyuser",
221+
sorted(
222+
models.Spappresourcedir.objects.filter(
223+
discipline=discipline,
224+
specifyuser__isnull=False,
225+
).values_list("id", flat=True)
226+
),
227+
),
228+
(
229+
"Sptasksemaphore",
230+
"owner",
231+
sorted(
232+
models.Sptasksemaphore.objects.filter(
233+
discipline=discipline,
234+
owner__isnull=False,
235+
).values_list("id", flat=True)
236+
),
237+
),
238+
)
239+
for table_name, field_name, blocker_ids in discipline_scoped_user_blockers:
240+
if blocker_ids:
241+
blockers.append(
242+
{
243+
"table": table_name,
244+
"field": field_name,
245+
"ids": blocker_ids,
246+
}
247+
)
248+
return blockers
249+
250+
def _raw_delete_queryset(queryset) -> None:
251+
queryset._raw_delete(queryset.db)
252+
253+
def delete_discipline_owned_setup_data(obj) -> None:
254+
"""
255+
Remove discipline-scoped setup/config rows in bulk before the final
256+
discipline delete so Django doesn't need to build one large collector graph.
257+
"""
258+
if not is_discipline(obj):
259+
return
260+
261+
from specifyweb.backend.businessrules.models import (
262+
UniquenessRule,
263+
UniquenessRuleField,
264+
)
265+
266+
schema_ids = models.Spexportschema.objects.filter(
267+
discipline_id=obj.id
268+
).values("id")
269+
export_item_ids = models.Spexportschemaitem.objects.filter(
270+
spexportschema_id__in=Subquery(schema_ids)
271+
).values("id")
272+
_raw_delete_queryset(
273+
models.Spexportschemaitemmapping.objects.filter(
274+
exportschemaitem_id__in=Subquery(export_item_ids)
275+
)
276+
)
277+
_raw_delete_queryset(
278+
models.Spexportschema_exportmapping.objects.filter(
279+
spexportschema_id__in=Subquery(schema_ids)
280+
)
281+
)
282+
_raw_delete_queryset(
283+
models.Spexportschemaitem.objects.filter(
284+
spexportschema_id__in=Subquery(schema_ids)
285+
)
286+
)
287+
_raw_delete_queryset(models.Spexportschema.objects.filter(discipline_id=obj.id))
288+
289+
container_ids = models.Splocalecontainer.objects.filter(
290+
discipline_id=obj.id
291+
).values("id")
292+
item_ids = models.Splocalecontaineritem.objects.filter(
293+
container_id__in=Subquery(container_ids)
294+
).values("id")
295+
_raw_delete_queryset(
296+
models.Splocaleitemstr.objects.filter(
297+
Q(containerdesc_id__in=Subquery(container_ids))
298+
| Q(containername_id__in=Subquery(container_ids))
299+
| Q(itemdesc_id__in=Subquery(item_ids))
300+
| Q(itemname_id__in=Subquery(item_ids))
301+
)
302+
)
303+
_raw_delete_queryset(
304+
models.Splocalecontaineritem.objects.filter(
305+
container_id__in=Subquery(container_ids)
306+
)
307+
)
308+
_raw_delete_queryset(models.Splocalecontainer.objects.filter(discipline_id=obj.id))
309+
310+
rule_ids = UniquenessRule.objects.filter(discipline_id=obj.id).values("id")
311+
_raw_delete_queryset(
312+
UniquenessRuleField.objects.filter(
313+
uniquenessrule_id__in=Subquery(rule_ids)
314+
)
315+
)
316+
_raw_delete_queryset(UniquenessRule.objects.filter(discipline_id=obj.id))
317+
318+
_raw_delete_queryset(models.Spappresourcedir.objects.filter(discipline_id=obj.id))
319+
_raw_delete_queryset(models.Sptasksemaphore.objects.filter(discipline_id=obj.id))
320+
_raw_delete_queryset(models.Autonumschdsp.objects.filter(discipline_id=obj.id))
321+
322+
def prepare_discipline_for_delete(obj) -> None:
323+
"""
324+
Detach discipline owned tree defs and bulk delete discipline-scoped setup
325+
data before deletion so empty disciplines delete quickly.
326+
"""
327+
if not is_discipline(obj):
328+
return
329+
330+
for tree_def_model in DISCIPLINE_TREE_MODELS:
331+
tree_def_model.objects.filter(discipline_id=obj.id).update(discipline_id=None)
332+
delete_discipline_owned_setup_data(obj)
191333

192334
@transaction.atomic
193335
def put_resource(collection, agent, name: str, id, version, data: dict[str, Any]):
@@ -308,8 +450,22 @@ def delete_resource(collection, agent, name, id, version) -> None:
308450
locking 'version'.
309451
"""
310452
obj = get_object_or_404(name, id=int(id))
311-
return delete_obj(obj, (make_default_deleter(collection, agent)), version)
312-
453+
if is_discipline(obj):
454+
guard_blockers = get_discipline_delete_guard_blockers(obj)
455+
if guard_blockers:
456+
raise BusinessRuleException(
457+
"Discipline cannot be deleted while it has associated users or collections."
458+
)
459+
clean_predelete = prepare_discipline_for_delete
460+
else:
461+
clean_predelete = None
462+
463+
return delete_obj(
464+
obj,
465+
make_default_deleter(collection, agent),
466+
version,
467+
clean_predelete=clean_predelete,
468+
)
313469

314470
def get_collection(logged_in_collection, model, checker: ReadPermChecker, control_params=GetCollectionForm.defaults, params={}) -> CollectionPayload:
315471
from specifyweb.specify.api.serializers import _obj_to_data

0 commit comments

Comments
 (0)