bottleneckcrm/backend/tasks/serializer.py

691 lines
23 KiB
Python

from django.db.models import Count, Q
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from common.modules import (
available_modules,
model_module,
module_is_available,
module_off_message,
org_of,
)
from common.serializer import (
AttachmentsSerializer,
CommentSerializer,
ProfileSerializer,
TagsSerializer,
TeamsSerializer,
UserSerializer,
)
from contacts.serializer import ContactLinkSerializer
from tasks.access import has_task_access, visible_tasks_qs
from tasks.models import (
Board,
BoardColumn,
BoardMember,
BoardTask,
Task,
TaskPipeline,
TaskStage,
)
class BoardMemberSerializer(serializers.ModelSerializer):
"""Serializer for board members"""
profile = ProfileSerializer(read_only=True)
profile_id = serializers.UUIDField(write_only=True, required=False)
class Meta:
model = BoardMember
fields = "__all__"
read_only_fields = ("id", "created_at", "updated_at", "board")
class BoardTaskSerializer(serializers.ModelSerializer):
"""Serializer for board tasks.
The write surface is deliberately narrow. A card write may set only its own
content (title, description, order, priority, due_date), and its assignees
(through the write-only ``assigned_to_ids``). The board views take that
key out of the body and parse it with ``payload_id_list`` before this
serializer sees it, then apply it after save, org-filtered and active
only; the field stays declared so the request schema documents it.
Everything else is locked down:
* ``column`` is applied server-side by the move endpoint (see
``BoardTaskDetailView.put``), so a card can't be mass-assigned into another
board's, or another org's, column.
* ``org``/``created_by``/``updated_by`` are server-derived audit facts.
(``created_by`` FKs ``common.User``, which is *not* org-scoped by RLS, so
leaving it writable let any user id be stamped onto a card.)
* the CRM-link FKs (``account``/``contact``/``opportunity``) are render-only
context, no UI sets them, and while writable they exposed the same
unfiltered, cross-org ``PrimaryKeyRelatedField`` queryset that
``TaskCreateSerializer`` explicitly hardens against.
"""
assigned_to = ProfileSerializer(many=True, read_only=True)
assigned_to_ids = serializers.ListField(
child=serializers.UUIDField(), write_only=True, required=False
)
account = serializers.SerializerMethodField()
is_completed = serializers.BooleanField(read_only=True)
is_overdue = serializers.BooleanField(read_only=True)
class Meta:
model = BoardTask
fields = "__all__"
read_only_fields = (
"id",
"created_at",
"updated_at",
"completed_at",
"org",
"column",
"created_by",
"updated_by",
"contact",
"opportunity",
)
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.opportunity_id and not module_is_available(
org_of(instance), "deals"
):
data["opportunity"] = None
return data
@extend_schema_field(dict)
def get_account(self, obj):
"""Render the parent account as ``{id, name}`` for the card chip, or
``None``. Read-only: overriding the model FK with a method field also
removes it from the write surface."""
if not obj.account_id:
return None
return {
"id": str(obj.account_id),
"name": getattr(obj.account, "name", "") or "",
}
class BoardColumnSerializer(serializers.ModelSerializer):
"""Serializer for board columns"""
tasks = BoardTaskSerializer(many=True, read_only=True)
task_count = serializers.SerializerMethodField()
class Meta:
model = BoardColumn
fields = "__all__"
read_only_fields = ("id", "created_at", "updated_at", "board", "org")
@extend_schema_field(int)
def get_task_count(self, obj):
return obj.tasks.count()
class BoardColumnListSerializer(serializers.ModelSerializer):
"""Simplified column serializer for lists"""
task_count = serializers.SerializerMethodField()
class Meta:
model = BoardColumn
fields = ["id", "name", "order", "color", "limit", "task_count"]
@extend_schema_field(int)
def get_task_count(self, obj):
return obj.tasks.count()
class BoardSerializer(serializers.ModelSerializer):
"""Serializer for boards"""
owner = ProfileSerializer(read_only=True)
columns = BoardColumnListSerializer(many=True, read_only=True)
members = BoardMemberSerializer(source="memberships", many=True, read_only=True)
member_count = serializers.SerializerMethodField()
task_count = serializers.SerializerMethodField()
class Meta:
model = Board
fields = "__all__"
read_only_fields = (
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"owner",
"org",
)
@extend_schema_field(int)
def get_member_count(self, obj):
return obj.members.count()
@extend_schema_field(int)
def get_task_count(self, obj):
return BoardTask.objects.filter(column__board=obj).count()
class BoardListSerializer(serializers.ModelSerializer):
"""Simplified board serializer for lists"""
owner = ProfileSerializer(read_only=True)
member_count = serializers.SerializerMethodField()
column_count = serializers.SerializerMethodField()
task_count = serializers.SerializerMethodField()
my_role = serializers.SerializerMethodField()
class Meta:
model = Board
fields = [
"id",
"name",
"description",
"owner",
"is_archived",
"member_count",
"column_count",
"task_count",
"my_role",
"created_at",
"updated_at",
]
@extend_schema_field(str)
def get_my_role(self, obj):
"""The requesting profile's role on this board (``owner``/``admin``/
``member``), or ``None`` when they have none.
Read-only and derived from the request, never the client. A client
cannot claim a role it does not hold. The board's own owner always
resolves to ``owner`` even if no explicit ``BoardMember`` row was created
for them, so a board created outside the API still gates correctly. The
frontend uses this to decide whether to offer column management, but the
backend (``BoardColumnListCreateView``) remains the enforcing boundary.
"""
request = self.context.get("request")
profile = getattr(request, "profile", None)
if profile is None:
return None
membership = next(
(m for m in obj.memberships.all() if m.profile_id == profile.id), None
)
if membership is not None:
return membership.role
if obj.owner_id == profile.id:
return "owner"
return None
@extend_schema_field(int)
def get_member_count(self, obj):
return obj.members.count()
@extend_schema_field(int)
def get_column_count(self, obj):
return obj.columns.count()
@extend_schema_field(int)
def get_task_count(self, obj):
return BoardTask.objects.filter(column__board=obj).count()
class _MinimalAccountField(serializers.RelatedField):
"""Account FK rendered as `{id, name}` so list rows can show the parent
entity without an extra round-trip. Falls back to id-only when the related
object is missing a name."""
def to_representation(self, value):
return {"id": str(value.pk), "name": getattr(value, "name", "") or ""}
class _ModuleLinkField(serializers.RelatedField):
"""A parent from another module: null while that module is off."""
module = None
def to_representation(self, value):
if not module_is_available(org_of(value), self.module):
return None
return {"id": str(value.pk), "name": self.name_of(value)}
def name_of(self, value):
return getattr(value, "name", "") or ""
class _MinimalOpportunityField(_ModuleLinkField):
module = "deals"
class _MinimalCaseField(_ModuleLinkField):
module = "tickets"
class _MinimalLeadField(_ModuleLinkField):
module = "leads"
def name_of(self, value):
first = getattr(value, "first_name", "") or ""
last = getattr(value, "last_name", "") or ""
return f"{first} {last}".strip() or (getattr(value, "email", "") or "")
class TaskListSerializer(serializers.ModelSerializer):
"""Slim payload for /api/tasks/ list pages. Drops the comment/attachment
bodies (sometimes hundreds of rows per task) and the contacts/teams M2Ms
that the list UI doesn't render. Use TaskSerializer for the detail view."""
assigned_to = ProfileSerializer(read_only=True, many=True)
tags = TagsSerializer(read_only=True, many=True)
account = _MinimalAccountField(read_only=True)
opportunity = _MinimalOpportunityField(read_only=True)
case = _MinimalCaseField(read_only=True)
lead = _MinimalLeadField(read_only=True)
class Meta:
model = Task
fields = (
"id",
"title",
"status",
"priority",
"due_date",
"account",
"opportunity",
"case",
"lead",
"assigned_to",
"tags",
"created_at",
)
class TaskSerializer(serializers.ModelSerializer):
created_by = UserSerializer()
assigned_to = ProfileSerializer(read_only=True, many=True)
# Name and email only; see `ContactLinkSerializer`.
contacts = ContactLinkSerializer(read_only=True, many=True)
teams = TeamsSerializer(read_only=True, many=True)
tags = TagsSerializer(read_only=True, many=True)
task_attachment = AttachmentsSerializer(read_only=True, many=True)
task_comments = CommentSerializer(read_only=True, many=True)
account = _MinimalAccountField(read_only=True)
opportunity = _MinimalOpportunityField(read_only=True)
case = _MinimalCaseField(read_only=True)
lead = _MinimalLeadField(read_only=True)
class Meta:
model = Task
fields = (
"id",
"title",
"status",
"priority",
"due_date",
"description",
"account",
"opportunity",
"case",
"lead",
"created_by",
"created_at",
"contacts",
"teams",
"assigned_to",
"tags",
"task_attachment",
"task_comments",
"custom_fields",
)
PARENT_FIELDS = ("account", "opportunity", "case", "lead")
class TaskCreateSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
request_obj = kwargs.pop("request_obj", None)
super().__init__(*args, **kwargs)
self.org = request_obj.profile.org
self.fields["title"].required = True
# A `ModelSerializer` builds each FK as `PrimaryKeyRelatedField(
# queryset=Model.objects.all())`: every account, lead, case and
# opportunity in the database, not in the org. Proven live: a task in
# one org was created with another org's account, and the list endpoint
# then rendered that org's account *name* back to the requester. Org is
# a server-derived fact; narrowing the queryset is what makes it one.
for name in PARENT_FIELDS:
self.fields[name].queryset = self.fields[name].queryset.filter(org=self.org)
def validate(self, attrs):
"""A task links to at most one parent entity.
The model says so too, in `Task.clean()`, and `Task.save()` calls
`full_clean()`, but Django's `ValidationError` is not DRF's, so the
model's refusal arrived as a 500. Checking here gets the client a 400
with the field named.
The check has to consider the parents already on the task, not only the
ones in this payload. Reading `attrs` alone was right for a create and
wrong for a `PATCH`: sending one parent to a task that already had a
different one looked like a single-parent request and walked straight
past the rule.
"""
attrs = super().validate(attrs)
stored = self.instance
for name in PARENT_FIELDS:
key = model_module(self.fields[name].queryset.model._meta.label)
if key is None or module_is_available(self.org, key) or name not in attrs:
continue
if attrs[name] is None:
# An off module's link reads as null, so a client sending the
# task back sends null: that keeps the stored link.
if stored is not None:
del attrs[name]
elif attrs[name].pk != getattr(stored, f"{name}_id", None):
raise serializers.ValidationError(
{name: module_off_message(self.org, key)}
)
resolved = {}
for name in PARENT_FIELDS:
if name in attrs:
resolved[name] = attrs[name]
elif self.instance is not None:
resolved[name] = getattr(self.instance, f"{name}_id", None)
else:
resolved[name] = None
set_parents = [name for name in PARENT_FIELDS if resolved[name]]
if len(set_parents) > 1:
raise serializers.ValidationError(
{
"account": (
"A task can only be linked to one parent entity "
f"(Account, Opportunity, Case, or Lead). "
f"Currently set: {', '.join(set_parents)}"
)
}
)
return attrs
class Meta:
model = Task
fields = (
"id",
"title",
"status",
"priority",
"due_date",
"description",
"account",
"opportunity",
"case",
"lead",
"created_by",
"created_at",
)
# `created_by` was writable, so a client could name anyone in any org
# the creator of a task. Proven live, a MicroPyramid task ended up
# created by a user of another org. That was a data-integrity bug while
# nothing read the field; the moment the creator clause in
# `tasks.access` starts returning True it is privilege escalation, so
# the two changes belong together.
read_only_fields = ("id", "created_by", "created_at")
class TaskDetailEditSwaggerSerializer(serializers.Serializer):
comment = serializers.CharField()
task_attachment = serializers.FileField()
class TaskCommentEditSwaggerSerializer(serializers.Serializer):
comment = serializers.CharField()
class TaskCreateSwaggerSerializer(serializers.ModelSerializer):
class Meta:
model = Task
fields = (
"title",
"status",
"priority",
"due_date",
"description",
"account",
"opportunity",
"case",
"lead",
"contacts",
"teams",
"assigned_to",
"tags",
)
# ============================================================================
# Kanban Serializers
# ============================================================================
def _visible_task_count(serializer, **lookup):
"""How many tasks matching ``lookup`` the requester may open.
``task_count`` once counted every task in the org, so a member read a
count that included tasks they cannot open. It follows ``visible_tasks_qs``
now, the rule the board and the list use. The request has to be in the
serializer context: a missing one is a KeyError, never an unscoped count.
"""
profile = serializer.context["request"].profile
return visible_tasks_qs(profile).filter(**lookup).count()
class TaskStageSerializer(serializers.ModelSerializer):
"""Serializer for task stages. Needs ``request`` in its context."""
task_count = serializers.SerializerMethodField()
class Meta:
model = TaskStage
fields = [
"id",
"name",
"order",
"color",
"stage_type",
"maps_to_status",
"wip_limit",
"task_count",
"created_at",
"updated_at",
]
read_only_fields = ("id", "created_at", "updated_at", "org")
@extend_schema_field(int)
def get_task_count(self, obj):
return _visible_task_count(self, stage=obj)
def validate_maps_to_status(self, value):
"""Only a real task status, or nothing.
The column is free text, and a stage mapped to anything else is one no
task can move into: `Task.save()` runs `full_clean()` and refuses the
status the move would set.
"""
if value in (None, ""):
return value
allowed = [choice for choice, _ in Task.STATUS_CHOICES]
if value not in allowed:
raise serializers.ValidationError(
f"maps_to_status must be one of {', '.join(allowed)}, or empty."
)
return value
class TaskPipelineSerializer(serializers.ModelSerializer):
"""Serializer for task pipelines with nested stages. Needs ``request`` in
its context."""
stages = TaskStageSerializer(many=True, read_only=True)
stage_count = serializers.SerializerMethodField()
task_count = serializers.SerializerMethodField()
class Meta:
model = TaskPipeline
fields = [
"id",
"name",
"description",
"is_default",
"is_active",
"stages",
"stage_count",
"task_count",
"created_at",
"updated_at",
]
# `is_active` only goes False through DELETE, which refuses a pipeline
# that still has records, as on lead pipelines. Writable here, a PUT
# skipped that refusal.
read_only_fields = ("id", "is_active", "created_at", "updated_at", "org")
@extend_schema_field(int)
def get_stage_count(self, obj):
return obj.stages.count()
@extend_schema_field(int)
def get_task_count(self, obj):
return _visible_task_count(self, stage__pipeline=obj)
class TaskPipelineListSerializer(serializers.ModelSerializer):
"""Simplified pipeline serializer for lists.
Both counts are read from annotations, so every queryset serialized here
must come through ``with_counts``: one query for the whole list, however
many pipelines. A pipeline without them raises AttributeError, which is
louder than a count that silently ignores who is asking.
"""
stage_count = serializers.SerializerMethodField()
task_count = serializers.SerializerMethodField()
class Meta:
model = TaskPipeline
fields = [
"id",
"name",
"description",
"is_default",
"is_active",
"stage_count",
"task_count",
"created_at",
]
@staticmethod
def with_counts(pipelines, profile):
# The task join repeats a stage once per task in it, hence distinct on
# the stage count. A task sits in one stage, so it is counted once.
return pipelines.annotate(
stage_count=Count("stages", distinct=True),
task_count=Count(
"stages__tasks",
filter=Q(stages__tasks__in=visible_tasks_qs(profile).values("pk")),
),
)
@extend_schema_field(int)
def get_stage_count(self, obj):
return obj.stage_count
@extend_schema_field(int)
def get_task_count(self, obj):
return obj.task_count
class RelatedEntitySerializer(serializers.Serializer):
"""Minimal serializer for related entities on kanban cards."""
id = serializers.UUIDField()
name = serializers.CharField()
class TaskKanbanCardSerializer(serializers.ModelSerializer):
"""Lightweight serializer for kanban cards (minimal fields for performance)."""
assigned_to = ProfileSerializer(read_only=True, many=True)
is_overdue = serializers.BooleanField(read_only=True)
related_entity = serializers.SerializerMethodField()
can_move = serializers.SerializerMethodField()
class Meta:
model = Task
fields = [
"id",
"title",
"status",
"priority",
"due_date",
"is_overdue",
"stage",
"kanban_order",
"assigned_to",
"related_entity",
"can_move",
"created_at",
]
@extend_schema_field(bool)
def get_can_move(self, obj):
"""Whether the viewer may move this card: the task access rule
`TaskMoveView` asserts, so the board offers a drag only where the move
is accepted. False without a request in context. `assigned_to` is
prefetched by the board, so this adds no query per card.
"""
request = self.context.get("request")
if request is None:
return False
return has_task_access(request.profile, obj)
@extend_schema_field(RelatedEntitySerializer(allow_null=True))
def get_related_entity(self, obj):
"""Return the related entity (account, lead, opportunity, or case) if
any; null when its module is off."""
if obj.account_id:
return {"id": obj.account_id, "name": obj.account.name, "type": "account"}
available = available_modules(org_of(obj))
if obj.lead_id and "leads" in available:
return {"id": obj.lead_id, "name": str(obj.lead), "type": "lead"}
if obj.opportunity_id and "deals" in available:
return {
"id": obj.opportunity_id,
"name": obj.opportunity.name,
"type": "opportunity",
}
if obj.case_id and "tickets" in available:
return {"id": obj.case_id, "name": obj.case.name, "type": "case"}
return None
class TaskMoveSerializer(serializers.Serializer):
"""Serializer for moving tasks in kanban."""
stage_id = serializers.UUIDField(required=False, allow_null=True)
status = serializers.ChoiceField(choices=Task.STATUS_CHOICES, required=False)
kanban_order = serializers.DecimalField(
max_digits=15, decimal_places=6, required=False
)
above_task_id = serializers.UUIDField(required=False, allow_null=True)
below_task_id = serializers.UUIDField(required=False, allow_null=True)
def validate(self, attrs):
# Must provide either stage_id or status
if not attrs.get("stage_id") and not attrs.get("status"):
raise serializers.ValidationError(
"Either stage_id or status must be provided"
)
return attrs