789 lines
31 KiB
Python
789 lines
31 KiB
Python
import json
|
|
from datetime import timedelta
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
from django.db.models import Count, Q
|
|
from django.utils import timezone
|
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
|
from rest_framework import serializers, status
|
|
from rest_framework.pagination import LimitOffsetPagination
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from accounts.access import visible_accounts_qs
|
|
from accounts.serializer import AccountPickerSerializer
|
|
from common.custom_fields import validate_payload as validate_custom_fields_payload
|
|
from common.lookups import get_on_visible_record_or_404
|
|
from common.models import (
|
|
Attachments,
|
|
Comment,
|
|
CustomFieldDefinition,
|
|
Profile,
|
|
Tags,
|
|
Teams,
|
|
)
|
|
from common.permissions import HasOrgContext
|
|
from common.serializer import (
|
|
AttachmentsSerializer,
|
|
CommentSerializer,
|
|
CustomFieldDefinitionSerializer,
|
|
ProfileSerializer,
|
|
TeamsSerializer,
|
|
)
|
|
from common.utils import create_attachment, validate_attachment
|
|
from common.validators import (
|
|
choice_list_param,
|
|
date_param,
|
|
payload_id_list,
|
|
uuid_list_param,
|
|
uuid_param,
|
|
)
|
|
from contacts.access import replace_visible_contacts, visible_contacts_qs
|
|
from contacts.serializer import ContactPickerSerializer
|
|
from tasks import swagger_params
|
|
from tasks.access import (
|
|
assert_task_access,
|
|
assert_task_delete_access,
|
|
get_task_or_404,
|
|
is_org_admin,
|
|
visible_tasks_qs,
|
|
)
|
|
from tasks.models import Task
|
|
from tasks.serializer import (
|
|
TaskCommentEditSwaggerSerializer,
|
|
TaskCreateSerializer,
|
|
TaskCreateSwaggerSerializer,
|
|
TaskDetailEditSwaggerSerializer,
|
|
TaskListSerializer,
|
|
TaskSerializer,
|
|
)
|
|
from tasks.updates import (
|
|
_model_errors,
|
|
create_task,
|
|
notify_newly_assigned,
|
|
update_task,
|
|
)
|
|
|
|
|
|
class TaskListView(APIView, LimitOffsetPagination):
|
|
model = Task
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
params = self.request.query_params
|
|
# `visible_tasks_qs` is the same rule the detail view enforces. It used
|
|
# to be spelled out here and spelled differently there, which is how a
|
|
# member came to have tasks in their list that answered 403, or, once
|
|
# the creator clause is live, tasks they created and could not open.
|
|
queryset = (
|
|
visible_tasks_qs(self.request.profile)
|
|
.select_related("account", "opportunity", "case", "lead")
|
|
.prefetch_related("assigned_to__user", "tags")
|
|
.order_by("-id")
|
|
)
|
|
|
|
if params:
|
|
if params.get("title"):
|
|
queryset = queryset.filter(title__icontains=params.get("title"))
|
|
# Repeatable: `?status=New&status=In Progress` is the only way to
|
|
# ask for "still open", which is what the dashboard's Overdue and
|
|
# Due Today counts mean. A single `?status=New` behaves exactly as
|
|
# it did, so no existing caller changes.
|
|
statuses = choice_list_param(
|
|
params, "status", [value for value, _label in Task.STATUS_CHOICES]
|
|
)
|
|
if statuses:
|
|
queryset = queryset.filter(status__in=statuses)
|
|
if params.get("priority"):
|
|
queryset = queryset.filter(priority=params.get("priority"))
|
|
assigned_to = uuid_list_param(params, "assigned_to")
|
|
if assigned_to:
|
|
queryset = queryset.filter(assigned_to__id__in=assigned_to).distinct()
|
|
tags = uuid_list_param(params, "tags")
|
|
if tags:
|
|
queryset = queryset.filter(tags__id__in=tags).distinct()
|
|
if params.get("search"):
|
|
queryset = queryset.filter(title__icontains=params.get("search"))
|
|
due_date_gte = date_param(params, "due_date__gte")
|
|
if due_date_gte:
|
|
queryset = queryset.filter(due_date__gte=due_date_gte)
|
|
due_date_lte = date_param(params, "due_date__lte")
|
|
if due_date_lte:
|
|
queryset = queryset.filter(due_date__lte=due_date_lte)
|
|
created_at_gte = date_param(params, "created_at__gte")
|
|
if created_at_gte:
|
|
queryset = queryset.filter(created_at__date__gte=created_at_gte)
|
|
created_at_lte = date_param(params, "created_at__lte")
|
|
if created_at_lte:
|
|
queryset = queryset.filter(created_at__date__lte=created_at_lte)
|
|
for related in ("account", "opportunity", "case", "lead"):
|
|
related_id = uuid_param(params, related)
|
|
if related_id:
|
|
queryset = queryset.filter(**{f"{related}_id": related_id})
|
|
for raw_key, raw_value in params.items():
|
|
if raw_key.startswith("cf_") and raw_value:
|
|
cf_key = raw_key[3:]
|
|
if cf_key:
|
|
queryset = queryset.filter(
|
|
custom_fields__contains={cf_key: raw_value}
|
|
)
|
|
context = {}
|
|
queryset = queryset.distinct()
|
|
|
|
# Totals over the whole filtered queryset, not the page, so a header
|
|
# that says "6 overdue" is not really saying "6 on this screen".
|
|
# `overdue` counts only tasks still open: a completed task that
|
|
# happened to finish late is not something anyone can act on, which is
|
|
# the same line the mock's `taskTotals` drew and the right one.
|
|
today = timezone.localdate()
|
|
week_out = today + timedelta(days=7)
|
|
# One aggregate, not seven `.count()` calls: written the obvious way
|
|
# this was eight COUNT(*) round trips per page load, all scanning the
|
|
# same rows. `distinct=True` because a non-admin's queryset joins the
|
|
# assignee many-to-many and would otherwise count a task once per
|
|
# assignee.
|
|
still_open = ~Q(status="Completed")
|
|
totals = queryset.aggregate(
|
|
count=Count("id", distinct=True),
|
|
open=Count("id", distinct=True, filter=still_open),
|
|
overdue=Count(
|
|
"id", distinct=True, filter=still_open & Q(due_date__lt=today)
|
|
),
|
|
due_today=Count("id", distinct=True, filter=still_open & Q(due_date=today)),
|
|
due_this_week=Count(
|
|
"id",
|
|
distinct=True,
|
|
filter=still_open & Q(due_date__gte=today, due_date__lte=week_out),
|
|
),
|
|
# A task with no due date is never overdue and never due this week,
|
|
# so it falls off both of the numbers above and out of anybody's
|
|
# attention. That is the one worth counting on its own.
|
|
no_due_date=Count(
|
|
"id", distinct=True, filter=still_open & Q(due_date__isnull=True)
|
|
),
|
|
unassigned=Count(
|
|
"id", distinct=True, filter=still_open & Q(assigned_to__isnull=True)
|
|
),
|
|
)
|
|
context["totals"] = totals
|
|
|
|
results_tasks = self.paginate_queryset(queryset, self.request, view=self)
|
|
# Slim serializer: drops comments/attachments/contacts/teams from list
|
|
# rows and renders FKs as {id, name}. Detail view still uses
|
|
# TaskSerializer for the full nested payload.
|
|
tasks = TaskListSerializer(results_tasks, many=True).data
|
|
if results_tasks:
|
|
offset = queryset.filter(id__gte=results_tasks[-1].id).count()
|
|
if offset == queryset.count():
|
|
offset = None
|
|
else:
|
|
offset = 0
|
|
context.update(
|
|
{
|
|
"tasks_count": self.count,
|
|
"offset": offset,
|
|
"tasks": tasks,
|
|
}
|
|
)
|
|
|
|
# What a task *form* needs, from the endpoint that already knows the
|
|
# answer, the same keys `cases` and `opportunity` publish, because a
|
|
# second name for the same list is how clients end up with two.
|
|
context["status"] = Task.STATUS_CHOICES
|
|
context["priority"] = Task.PRIORITY_CHOICES
|
|
profiles = Profile.objects.filter(
|
|
is_active=True, org=self.request.profile.org
|
|
).order_by("user__email")
|
|
if not is_org_admin(self.request.profile):
|
|
profiles = profiles.filter(role="ADMIN")
|
|
context["users"] = list(profiles.values("id", "user__email"))
|
|
# The catalogues are for the parent picker and grow with the org, not
|
|
# with the page, `?slim=true` omits them for callers that only want
|
|
# the list. Each offers exactly the records its own detail view would
|
|
# open for this caller, by calling that module's read rule: accounts
|
|
# to admins, superusers, the creator and assignees; contacts to the
|
|
# same, plus anyone assigned to one of the contact's accounts. This
|
|
# endpoint used to hand every member the whole org's accounts and
|
|
# contacts in full.
|
|
if params.get("slim") != "true":
|
|
accounts = visible_accounts_qs(self.request.profile)
|
|
contacts = visible_contacts_qs(self.request.profile)
|
|
context["accounts_list"] = AccountPickerSerializer(accounts, many=True).data
|
|
context["contacts_list"] = ContactPickerSerializer(contacts, many=True).data
|
|
return context
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
operation_id="tasks_list",
|
|
parameters=swagger_params.task_list_get_params,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskListResponse",
|
|
fields={
|
|
"tasks_count": serializers.IntegerField(),
|
|
"offset": serializers.IntegerField(allow_null=True),
|
|
"tasks": TaskListSerializer(many=True),
|
|
"totals": serializers.DictField(),
|
|
"status": serializers.ListField(),
|
|
"priority": serializers.ListField(),
|
|
"users": serializers.ListField(),
|
|
"accounts_list": AccountPickerSerializer(many=True),
|
|
"contacts_list": ContactPickerSerializer(many=True),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def get(self, request, *args, **kwargs):
|
|
context = self.get_context_data(**kwargs)
|
|
return Response(context)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
operation_id="tasks_create",
|
|
parameters=swagger_params.organization_params,
|
|
request=TaskCreateSwaggerSerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskCreateResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request, *args, **kwargs):
|
|
"""Create a task.
|
|
|
|
The write itself is `tasks.updates.create_task`, shared with
|
|
automation rules.
|
|
"""
|
|
task, errors = create_task(request, request.data)
|
|
if task is None:
|
|
return Response(
|
|
{"error": True, "errors": errors},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
return Response(
|
|
{"error": False, "message": "Task Created Successfully"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TaskDetailView(APIView):
|
|
model = Task
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
def get_object(self, pk):
|
|
return get_task_or_404(self.request.profile, pk)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = {}
|
|
# Raise, don't return. This used to hand a `Response` back to `get()`,
|
|
# which wrapped it in a second `Response`, so the one path that was
|
|
# supposed to say 403 answered `TypeError: Object of type Response is
|
|
# not JSON serializable` and a 500 instead.
|
|
assert_task_access(self.request.profile, self.task_obj)
|
|
|
|
task_content_type = ContentType.objects.get_for_model(Task)
|
|
comments = Comment.objects.filter(
|
|
content_type=task_content_type,
|
|
object_id=self.task_obj.id,
|
|
org=self.request.profile.org,
|
|
).order_by("-id")
|
|
attachments = Attachments.objects.filter(
|
|
content_type=task_content_type,
|
|
object_id=self.task_obj.id,
|
|
org=self.request.profile.org,
|
|
).order_by("-id")
|
|
|
|
assigned_data = self.task_obj.assigned_to.values("id", "user__email")
|
|
|
|
# `created_by` is a `User`, so `created_by.user` does not exist. The
|
|
# branch guarding it compared a `Profile` to that `User` and was
|
|
# therefore always true, which made this line unreachable-by-intent and
|
|
# unavoidable-in-practice: every non-admin who opened any task got an
|
|
# AttributeError and a 500. Not "a member cannot open someone else's
|
|
# task". A member could not open *their own*.
|
|
if is_org_admin(self.request.profile):
|
|
users_mention = list(
|
|
Profile.objects.filter(
|
|
is_active=True, org=self.request.profile.org
|
|
).values("user__email")
|
|
)
|
|
elif self.request.profile.user_id != self.task_obj.created_by_id:
|
|
users_mention = (
|
|
[{"username": self.task_obj.created_by.email}]
|
|
if self.task_obj.created_by_id
|
|
else []
|
|
)
|
|
else:
|
|
users_mention = list(self.task_obj.assigned_to.all().values("user__email"))
|
|
if is_org_admin(self.request.profile):
|
|
users = Profile.objects.filter(
|
|
is_active=True, org=self.request.profile.org
|
|
).order_by("user__email")
|
|
else:
|
|
users = Profile.objects.filter(
|
|
role="ADMIN", org=self.request.profile.org
|
|
).order_by("user__email")
|
|
|
|
team_ids = [user.id for user in self.task_obj.get_team_users]
|
|
all_user_ids = users.values_list("id", flat=True)
|
|
users_excluding_team_id = set(all_user_ids) - set(team_ids)
|
|
users_excluding_team = Profile.objects.filter(id__in=users_excluding_team_id)
|
|
cf_definitions = CustomFieldDefinition.objects.filter(
|
|
org=self.request.profile.org,
|
|
target_model="Task",
|
|
is_active=True,
|
|
).order_by("display_order", "label")
|
|
context.update(
|
|
{
|
|
"task_obj": TaskSerializer(self.task_obj).data,
|
|
"attachments": AttachmentsSerializer(attachments, many=True).data,
|
|
"comments": CommentSerializer(comments, many=True).data,
|
|
"users_mention": users_mention,
|
|
"assigned_data": assigned_data,
|
|
"custom_field_definitions": CustomFieldDefinitionSerializer(
|
|
cf_definitions, many=True
|
|
).data,
|
|
}
|
|
)
|
|
context["users"] = ProfileSerializer(users, many=True).data
|
|
context["users_excluding_team"] = ProfileSerializer(
|
|
users_excluding_team, many=True
|
|
).data
|
|
context["teams"] = TeamsSerializer(
|
|
Teams.objects.filter(org=self.request.profile.org), many=True
|
|
).data
|
|
return context
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
operation_id="tasks_retrieve",
|
|
parameters=swagger_params.organization_params,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskDetailResponse",
|
|
fields={
|
|
"task_obj": TaskSerializer(),
|
|
"attachments": AttachmentsSerializer(many=True),
|
|
"comments": CommentSerializer(many=True),
|
|
"users_mention": serializers.ListField(),
|
|
"assigned_data": serializers.DictField(),
|
|
"users": ProfileSerializer(many=True),
|
|
"users_excluding_team": ProfileSerializer(many=True),
|
|
"teams": TeamsSerializer(many=True),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def get(self, request, pk, **kwargs):
|
|
self.task_obj = self.get_object(pk)
|
|
context = self.get_context_data(**kwargs)
|
|
return Response(context)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
operation_id="tasks_comment_attachment",
|
|
parameters=swagger_params.organization_params,
|
|
request=TaskDetailEditSwaggerSerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskCommentAttachmentResponse",
|
|
fields={
|
|
"task_obj": TaskSerializer(),
|
|
"attachments": AttachmentsSerializer(many=True),
|
|
"comments": CommentSerializer(many=True),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def post(self, request, pk, **kwargs):
|
|
params = request.data
|
|
context = {}
|
|
self.task_obj = self.get_object(pk)
|
|
assert_task_access(request.profile, self.task_obj)
|
|
# Before the comment is saved, so a refused file does not leave it posted.
|
|
validate_attachment(self.request.FILES.get("task_attachment"))
|
|
task_content_type = ContentType.objects.get_for_model(Task)
|
|
comment_text = params.get("comment")
|
|
if comment_text:
|
|
# Use the generic ContentType-based create directly. Validating
|
|
# through CommentSerializer fails silently here because that
|
|
# serializer requires object_id and org, neither of which the
|
|
# client sends; is_valid() returns False and the save is skipped,
|
|
# leaving the user with a 200 and no comment.
|
|
Comment.objects.create(
|
|
content_type=task_content_type,
|
|
object_id=self.task_obj.id,
|
|
comment=comment_text,
|
|
commented_by=self.request.profile,
|
|
org=self.request.profile.org,
|
|
)
|
|
|
|
if self.request.FILES.get("task_attachment"):
|
|
create_attachment(
|
|
self.request.FILES.get("task_attachment"),
|
|
self.task_obj,
|
|
self.request.profile,
|
|
)
|
|
|
|
comments = Comment.objects.filter(
|
|
content_type=task_content_type,
|
|
object_id=self.task_obj.id,
|
|
org=self.request.profile.org,
|
|
).order_by("-id")
|
|
attachments = Attachments.objects.filter(
|
|
content_type=task_content_type,
|
|
object_id=self.task_obj.id,
|
|
org=self.request.profile.org,
|
|
).order_by("-id")
|
|
context.update(
|
|
{
|
|
"task_obj": TaskSerializer(self.task_obj).data,
|
|
"attachments": AttachmentsSerializer(attachments, many=True).data,
|
|
"comments": CommentSerializer(comments, many=True).data,
|
|
}
|
|
)
|
|
return Response(context)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
operation_id="tasks_update",
|
|
parameters=swagger_params.organization_params,
|
|
request=TaskCreateSwaggerSerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskUpdateResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def put(self, request, pk, **kwargs):
|
|
params = request.data
|
|
self.task_obj = self.get_object(pk)
|
|
assert_task_access(request.profile, self.task_obj)
|
|
serializer = TaskCreateSerializer(
|
|
data=params,
|
|
instance=self.task_obj,
|
|
request_obj=request,
|
|
)
|
|
if serializer.is_valid():
|
|
save_kwargs = {}
|
|
if "custom_fields" in params:
|
|
cf_payload = params.get("custom_fields")
|
|
if isinstance(cf_payload, str):
|
|
try:
|
|
cf_payload = json.loads(cf_payload)
|
|
except (TypeError, ValueError):
|
|
cf_payload = None
|
|
cleaned_cf, cf_errors = validate_custom_fields_payload(
|
|
"Task",
|
|
cf_payload or {},
|
|
request.profile.org,
|
|
existing=self.task_obj.custom_fields or {},
|
|
)
|
|
if cf_errors:
|
|
return Response(
|
|
{"error": True, "errors": {"custom_fields": cf_errors}},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
save_kwargs["custom_fields"] = cleaned_cf
|
|
# Parsed before the first write, so a malformed id leaves the task
|
|
# exactly as it was.
|
|
contact_ids = payload_id_list(params.get("contacts"), "contacts")
|
|
team_ids = payload_id_list(params.get("teams"), "teams")
|
|
assigned_ids = payload_id_list(params.get("assigned_to"), "assigned_to")
|
|
tag_ids = payload_id_list(params.get("tags"), "tags")
|
|
try:
|
|
task_obj = serializer.save(**save_kwargs)
|
|
except DjangoValidationError as exc:
|
|
return Response(
|
|
{"error": True, "errors": _model_errors(exc)},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
replace_visible_contacts(task_obj.contacts, contact_ids, request.profile)
|
|
|
|
task_obj.teams.clear()
|
|
if team_ids:
|
|
teams = Teams.objects.filter(id__in=team_ids, org=request.profile.org)
|
|
task_obj.teams.add(*teams)
|
|
|
|
previous_assignee_ids = list(
|
|
task_obj.assigned_to.values_list("id", flat=True)
|
|
)
|
|
task_obj.assigned_to.clear()
|
|
if assigned_ids:
|
|
profiles = Profile.objects.filter(
|
|
id__in=assigned_ids, org=request.profile.org, is_active=True
|
|
)
|
|
task_obj.assigned_to.add(*profiles)
|
|
|
|
task_obj.tags.clear()
|
|
if tag_ids:
|
|
tag_objs = Tags.objects.filter(
|
|
id__in=tag_ids, org=request.profile.org, is_active=True
|
|
)
|
|
task_obj.tags.add(*tag_objs)
|
|
|
|
notify_newly_assigned(request, task_obj, previous_assignee_ids)
|
|
|
|
# The parent FKs are the serializer's job now. It scopes all four
|
|
# querysets to the org, so an id from somewhere else is a 400 here
|
|
# instead of a silent `None`. A form that says "link this to Acme"
|
|
# and gets a 200 back should not have unlinked it.
|
|
return Response(
|
|
{"error": False, "message": "Task updated Successfully"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
return Response(
|
|
{"error": True, "errors": serializer.errors},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
parameters=swagger_params.organization_params,
|
|
request=TaskCreateSwaggerSerializer,
|
|
description="Partial Task Update",
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskPatchResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def patch(self, request, pk, **kwargs):
|
|
"""Handle partial updates to a task.
|
|
|
|
The write itself is `tasks.updates.update_task`, shared with
|
|
automation rules.
|
|
"""
|
|
self.task_obj = self.get_object(pk)
|
|
assert_task_access(request.profile, self.task_obj)
|
|
errors = update_task(request, self.task_obj, request.data)
|
|
if errors is not None:
|
|
return Response(
|
|
{"error": True, "errors": errors},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
return Response(
|
|
{"error": False, "message": "Task updated Successfully"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
operation_id="tasks_destroy",
|
|
parameters=swagger_params.organization_params,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskDeleteResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def delete(self, request, pk, **kwargs):
|
|
self.object = self.get_object(pk)
|
|
# Narrower than `access`: an assignee may work the task, not erase it.
|
|
assert_task_delete_access(request.profile, self.object)
|
|
self.object.delete()
|
|
return Response(
|
|
{"error": False, "message": "Task deleted Successfully"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class TaskCommentView(APIView):
|
|
model = Comment
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
def get_object(self, pk):
|
|
"""A comment on a task the requester may open, or 404.
|
|
|
|
Was an org-wide lookup across every module's comments, then a 403
|
|
for a non-author: that reached another module's comments and
|
|
confirmed a hidden comment's id. A missing id, a malformed one,
|
|
another module's comment and one on a task this caller cannot open
|
|
now raise the same 404.
|
|
"""
|
|
return get_on_visible_record_or_404(
|
|
self.model,
|
|
pk,
|
|
self.request.profile.org,
|
|
visible_tasks_qs(self.request.profile),
|
|
)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
parameters=swagger_params.organization_params,
|
|
request=TaskCommentEditSwaggerSerializer,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskCommentUpdateResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def put(self, request, pk, format=None):
|
|
params = request.data
|
|
obj = self.get_object(pk)
|
|
if is_org_admin(request.profile) or request.profile == obj.commented_by:
|
|
# No `if params.get("comment")` guard here: a body with a blank or
|
|
# absent `comment` used to fall out of this branch and hit the 403
|
|
# below, which told an author they may not edit their own comment.
|
|
# `comment` is a non-blank CharField, so the serializer answers
|
|
# with a 400 naming the field.
|
|
serializer = CommentSerializer(obj, data=params)
|
|
if serializer.is_valid():
|
|
serializer.save()
|
|
return Response(
|
|
{"error": False, "message": "Comment Submitted"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
return Response(
|
|
{"error": True, "errors": serializer.errors},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
return Response(
|
|
{
|
|
"error": True,
|
|
"errors": "You don't have Permission to perform this action",
|
|
},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
parameters=swagger_params.organization_params,
|
|
request=TaskCommentEditSwaggerSerializer,
|
|
description="Partial Comment Update",
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskCommentPatchResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def patch(self, request, pk, format=None):
|
|
"""Handle partial updates to a comment."""
|
|
params = request.data
|
|
obj = self.get_object(pk)
|
|
if is_org_admin(request.profile) or request.profile == obj.commented_by:
|
|
serializer = CommentSerializer(obj, data=params, partial=True)
|
|
if serializer.is_valid():
|
|
serializer.save()
|
|
return Response(
|
|
{"error": False, "message": "Comment Updated"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
return Response(
|
|
{"error": True, "errors": serializer.errors},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
return Response(
|
|
{
|
|
"error": True,
|
|
"errors": "You don't have Permission to perform this action",
|
|
},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
parameters=swagger_params.organization_params,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskCommentDeleteResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def delete(self, request, pk, format=None):
|
|
self.object = self.get_object(pk)
|
|
if is_org_admin(request.profile) or request.profile == self.object.commented_by:
|
|
self.object.delete()
|
|
return Response(
|
|
{"error": False, "message": "Comment Deleted Successfully"},
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
return Response(
|
|
{
|
|
"error": True,
|
|
"errors": "You don't have Permission to perform this action",
|
|
},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
|
|
class TaskAttachmentView(APIView):
|
|
model = Attachments
|
|
permission_classes = (IsAuthenticated, HasOrgContext)
|
|
|
|
@extend_schema(
|
|
tags=["Tasks"],
|
|
parameters=swagger_params.organization_params,
|
|
responses={
|
|
200: inline_serializer(
|
|
name="TaskAttachmentDeleteResponse",
|
|
fields={
|
|
"error": serializers.BooleanField(),
|
|
"message": serializers.CharField(),
|
|
},
|
|
)
|
|
},
|
|
)
|
|
def delete(self, request, pk, format=None):
|
|
# `Attachments` is one generic table shared by every module. A lookup
|
|
# by pk alone once let an admin of one org delete another org's lead
|
|
# file; an org-wide lookup still reached every module's files in the
|
|
# org and answered 403 for a hidden one. Only an attachment on a task
|
|
# this caller may open is found; anything else is the same 404 as a
|
|
# missing id. The uploader clause below is a separate check.
|
|
self.object = get_on_visible_record_or_404(
|
|
self.model,
|
|
pk,
|
|
request.profile.org,
|
|
visible_tasks_qs(request.profile),
|
|
)
|
|
# `created_by` is a `User`; `request.profile` is a `Profile`. Comparing
|
|
# them is always False, so the person who uploaded the file could not
|
|
# remove it unless they were an admin.
|
|
if not (
|
|
is_org_admin(request.profile)
|
|
or request.profile.user_id == self.object.created_by_id
|
|
):
|
|
return Response(
|
|
{
|
|
"error": True,
|
|
"errors": "You don't have Permission to perform this action",
|
|
},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
self.object.delete()
|
|
return Response(
|
|
{"error": False, "message": "Attachment Deleted Successfully"},
|
|
status=status.HTTP_200_OK,
|
|
)
|