bottleneckcrm/backend/cases/parent_guards.py
Ashwin e5b6beb4ca Refactor permissions and remove unused settings
- Replaced custom admin check with `is_org_admin` in `custom_field_views.py`, `organization_views.py`, `user_views.py`, and `macros/views.py` for consistency and clarity.
- Removed the unused `_is_admin` function from `custom_field_views.py` and `macros/views.py`.
- Updated organization-related views to streamline admin checks and improve error handling.
- Removed `SWAGGER_ROOT_URL` and `GOOGLE_REDIRECT_URI` settings from `settings.py` as they were not utilized.
- Cleaned up Swagger parameters across various modules to remove the now-irrelevant `org` header.
- Adjusted documentation to reflect the removal of `GOOGLE_REDIRECT_URI` and clarify Google OAuth configuration.
- Added database indexes for `Order` and `OrderLineItem` models to improve query performance.
2026-08-05 09:11:11 +05:30

84 lines
3.4 KiB
Python

"""The parent/child linking rules, in one place both write paths can call.
``Case.clean()`` declares four rules about linking a case under a parent: no
self-parent, no cycle, no more than ``Case.PARENT_MAX_DEPTH`` levels, and
nothing linked to or from a merged (``Duplicate``) case. Nothing on the save
path calls ``full_clean()`` and DRF never calls ``Model.clean()``, so those
rules only ever ran where a view or serializer reimplemented them.
``CaseLinkParentView`` did reimplement all four. ``CaseSerializer`` reimplemented
one and a half, and ``parent`` is writable there, so
``PATCH /api/cases/<id>/ {"parent": ...}`` could store a cycle that the link
endpoint's ancestor walk then followed forever. Writing the rules a third time
in the serializer would have set up the same drift again, which is why they
live here instead.
"""
from cases.models import Case
MAX_DEPTH = Case.PARENT_MAX_DEPTH
def max_subtree_depth(case, depth=0, seen=None):
"""Depth of the deepest descendant under ``case``. ``case`` itself = 0."""
if seen is None:
seen = set()
if case.id in seen:
return depth
seen.add(case.id)
children = list(case.children.all())
if not children:
return depth
return max(max_subtree_depth(c, depth + 1, seen) for c in children)
def check_parent_link(parent, *, case=None, case_status=None):
"""Return a message explaining why this link is refused, or ``None``.
``parent`` is the case being linked to, already resolved and confirmed to
be in the caller's org. ``case`` is the record being moved, or ``None`` on
create, where there is no row yet to close a cycle with and no subtree to
carry along. ``case_status`` overrides the stored status, for a request
that changes status and parent together.
A message rather than a raised exception: the two callers report errors
differently (a DRF ``ValidationError`` against the ``parent`` field, and a
hand-built 400 body keyed ``parent_id``), and neither shape belongs here.
"""
if parent is None:
return None
if case is not None and parent.id == case.id:
return "A case cannot be its own parent."
status = case_status if case_status is not None else getattr(case, "status", None)
if parent.status == "Duplicate" or status == "Duplicate":
return "Cannot link to or from a merged case."
# Walk up from the proposed parent. `seen` is not redundant with the
# `case.id` test: a cycle that does not pass through `case` still makes
# this loop run forever, and one such cycle is reachable in any database
# written before this guard existed.
seen = set()
cursor = parent
depth_above = 0
while cursor is not None:
if case is not None and cursor.id == case.id:
return "Linking would create a cycle."
if cursor.id in seen:
return (
"This case's parent chain already contains a cycle, so nothing "
"can be linked under it."
)
seen.add(cursor.id)
depth_above += 1
cursor = cursor.parent
# Levels above the parent, plus the parent, plus whatever `case` brings
# with it. Re-parenting moves a whole subtree, so a case with children of
# its own does not fit everywhere a leaf would.
subtree_below = max_subtree_depth(case) if case is not None else 0
if depth_above + 1 + subtree_below > MAX_DEPTH:
return f"Case tree is limited to {MAX_DEPTH} levels."
return None