bottleneckcrm/backend/tasks/access.py
Ashwin bd79a7a4cc Refactor code style and improve error handling in analytics API
- Cleaned up admin.py by removing unnecessary dictionary wrapping for fields.
- Adjusted migration files to maintain consistent formatting and removed redundant lines.
- Updated models.py and tests to remove unnecessary blank lines and improve readability.
- Enhanced error handling in the analytics API to properly forward upstream status and messages.
- Added comprehensive tests for the analytics proxy to ensure correct status and body forwarding.
- Updated contributing guidelines to clarify the use of ruff for code style enforcement.
2026-08-07 15:58:35 +05:30

96 lines
3.9 KiB
Python

"""Who may do what to a task, two rules, beside the three in `cases.access`.
access admin · creator · assignee read it, edit it, comment on it
delete admin · creator destroy it
Two, not three, and that is the point of writing them down. A case splits
reading from writing because a *watcher* can follow a ticket without being
handed it; a task has no watcher, so everyone who may open one may also work
it. Deleting is narrower for the same reason it is narrower for a case: being
handed a task is a reason to do it, not a reason to erase it.
The list has to agree with the detail view or the queue lies, so `visible_tasks_qs`
is the queryset form of `access` and the two are derived from the same clauses.
**`created_by` is a `User`, not a `Profile`.** Every check this module replaced
compared `request.profile` to it, which is never equal, so the creator clause
was dead everywhere it appeared, and the list, which got it right with
`Q(created_by=profile.user)`, listed tasks the detail view then refused. The
comparison here is `profile.user_id == task.created_by_id`.
That dead clause is also why `created_by` has to be `read_only` on the
serializer, and the two changes belong in one commit: while nothing consults
the field, writing to it is a data-integrity bug; the moment the creator clause
starts returning `True`, a client that can PATCH a task can name itself the
creator and grant itself delete.
"""
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import Q
from django.http import Http404
from rest_framework.exceptions import PermissionDenied
# Re-exported, not redefined, the same arrangement `cases.access` uses. This
# was an eleventh copy of the admin rule and the only one that had taken the
# canonical name, so the guard test looking for private spellings walked past
# it. It read `role == "ADMIN" or is_admin`, which is now one fact and not two.
from common.permissions import is_org_admin # noqa: F401
from tasks.models import Task
_DENIED = "You do not have Permission to perform this action"
def visible_tasks_qs(profile):
"""Tasks ``profile`` is allowed to open, the queryset form of `access`."""
qs = Task.objects.filter(org=profile.org)
if is_org_admin(profile):
return qs
return qs.filter(Q(created_by=profile.user) | Q(assigned_to=profile)).distinct()
def get_task_or_404(profile, pk):
"""Fetch a task in the requester's org, or raise ``Http404``.
``Task.id`` is a UUID column and the URL captures ``<str:pk>``, so
``filter(pk="nope")`` raises Django's ``ValidationError`` rather than
matching nothing, which surfaced as a 500 on every verb, including the
two that only need to say "no such task".
"""
try:
task = Task.objects.filter(pk=pk, org=profile.org).first()
except (DjangoValidationError, ValueError):
raise Http404("No such task.")
if task is None:
raise Http404("No such task.")
return task
def has_task_access(profile, task):
"""Non-raising form of `access`, for deciding what to put in a response."""
if is_org_admin(profile):
return True
if profile.user_id == task.created_by_id:
return True
return profile.id in {p.id for p in task.assigned_to.all()}
def assert_task_access(profile, task):
"""Raise 403 unless ``profile`` may open or change ``task``."""
if not has_task_access(profile, task):
raise PermissionDenied(_DENIED)
def has_task_delete_access(profile, task):
"""Non-raising form of `delete`."""
if is_org_admin(profile):
return True
return profile.user_id == task.created_by_id
def assert_task_delete_access(profile, task):
"""Raise 403 unless ``profile`` may destroy ``task``.
Narrower than `access` on purpose: see the module docstring.
"""
if not has_task_delete_access(profile, task):
raise PermissionDenied(_DENIED)