bottleneckcrm/backend/common/module_deletes.py

124 lines
4.4 KiB
Python

"""Deletes that would reach an off module's records answer 409 instead.
While a module is off its records are out of sight, so deleting an account,
ticket or person that would cascade into them, or is blocked by them, names
them rather than destroying them. ``common/tests/test_module_deletes.py`` walks
every model, so a new link into a module cannot be left out of ``DELETE_GUARDS``.
"""
from dataclasses import dataclass
from django.apps import apps
from django.db import models
from common.modules import (
Requires,
available_modules,
model_module,
module,
module_label,
org_of,
)
@dataclass(frozen=True)
class Guard:
child: str
field: str
noun: str
plural: str
DELETE_GUARDS: dict[str, tuple[Guard, ...]] = {
"accounts.Account": (
Guard("cases.Case", "account", "ticket", "tickets"),
Guard("opportunity.Opportunity", "account", "deal", "deals"),
Guard("invoices.Invoice", "account", "invoice", "invoices"),
Guard("invoices.Estimate", "account", "estimate", "estimates"),
Guard(
"invoices.RecurringInvoice",
"account",
"recurring invoice",
"recurring invoices",
),
),
"cases.Case": (Guard("cases.TimeEntry", "case", "time entry", "time entries"),),
"common.Profile": (
Guard("cases.TimeEntry", "profile", "time entry", "time entries"),
Guard("cases.Approval", "requested_by", "approval", "approvals"),
Guard("cases.Approval", "approver", "approval", "approvals"),
Guard("cases.CaseWatcher", "profile", "watched ticket", "watched tickets"),
Guard("macros.Macro", "owner", "saved reply", "saved replies"),
Guard("tasks.Board", "owner", "board", "boards"),
Guard("tasks.BoardMember", "profile", "board membership", "board memberships"),
),
}
# The models whose delete views call `delete_refusal`. Any other key above is
# consulted only when deleting one of these cascades into it.
ENTRY_POINTS = frozenset({"accounts.Account", "cases.Case", "common.Profile"})
def _blocking(guard, available):
"""The module keeping ``guard.child`` out of sight: its need first, so a
time entry hidden by Tickets names Tickets."""
key = model_module(guard.child)
needs = module(key).needs
return Requires((needs, key) if needs else (key,)).blocking(available)
def _rows(guard, parent):
"""``parent`` is the record being deleted, or a queryset of rows its
delete cascades into."""
lookup = guard.field if isinstance(parent, models.Model) else f"{guard.field}__in"
return apps.get_model(guard.child)._base_manager.filter(**{lookup: parent})
def cascades(guard) -> bool:
"""Whether deleting the parent deletes ``guard``'s rows, in Django or in
the database."""
field = apps.get_model(guard.child)._meta.get_field(guard.field)
return field.remote_field.on_delete in (models.CASCADE, models.DB_CASCADE)
def _refusal(org, available, label, parent, subject):
guards = DELETE_GUARDS.get(label, ())
for guard in guards:
key = _blocking(guard, available)
if key is None:
continue
count = _rows(guard, parent).count()
if count:
return _body(org, key, subject, guard, count)
# Rows in an available module that go with the delete can carry rows of an
# off one: an account's tickets take their time entries with them.
for guard in guards:
if _blocking(guard, available) is None and cascades(guard):
found = _refusal(org, available, guard.child, _rows(guard, parent), subject)
if found:
return found
return None
def _body(org, key, subject, guard, count):
label = module_label(org, key)
if count == 1:
message = (
f"{subject} has 1 {guard.noun}. {label} is switched off, so switch it "
"on to review it before deleting."
)
else:
message = (
f"{subject} has {count} {guard.plural}. {label} is switched off, so "
"switch it on to review them before deleting."
)
return {"error": True, "errors": message, "code": "module_off", "module": key}
def delete_refusal(instance, subject: str) -> dict | None:
"""The 409 body when deleting ``instance`` would reach records of a module
that is off for its org, else None."""
org = org_of(instance)
return _refusal(
org, available_modules(org), instance._meta.label, instance, subject
)