bottleneckcrm/backend/common/serializer.py

1463 lines
52 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
from disposable_email_domains import blocklist as disposable_domains
from django.core.exceptions import ValidationError as DjangoValidationError
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from rest_framework_simplejwt.tokens import RefreshToken
from common.custom_fields import validate_definition_options
from common.links import frontend_url
from common.models import (
Activity,
Address,
APISettings,
Attachments,
Comment,
CustomFieldDefinition,
Document,
Notification,
Org,
PersonalAccessToken,
Profile,
Tags,
Teams,
User,
)
from common.modules import module_is_available, module_off_message
from common.object_registry import BUILT_IN, object_module
from common.permissions import is_org_admin
from common.utils import CURRENCY_SYMBOLS, validate_upload_size
from common.validators import flexible_phone_validator, validate_help_center_slug
# Safe at module level: contacts.models imports common.models and common.base,
# never common.serializer, so this does not close a cycle.
from contacts.models import Contact
class OrgAwareRefreshToken(RefreshToken):
"""
Custom RefreshToken that includes org context in the token payload.
This ensures the org context is cryptographically signed and cannot be
forged by the client. The middleware should validate org_id from this
token instead of trusting the org header.
Embedded claims (to avoid extra API calls):
- org_id: Organization UUID
- org_name: Organization name (for display)
- role: User's role in the org (ADMIN/USER)
- is_organization_admin: ``is_org_admin(profile)``, the one admin fact
both clients gate admin UI on (the role, or a superuser's profile)
- org_settings: Currency, locale and timezone settings
The clients read these claims for display and to hide controls only. The
API never trusts them: every request re-resolves the profile from the
database, and ``OrgAwareTokenRefreshView`` re-derives them on each refresh.
"""
@classmethod
def for_user_and_org(cls, user, org, profile=None):
"""
Generate a refresh token for a user with org context.
Args:
user: User instance
org: Org instance or org_id UUID
profile: Optional Profile instance for role
Returns:
OrgAwareRefreshToken with org claims
"""
token = cls.for_user(user)
# Add user info to token (avoids extra API calls for display)
if user:
token["user_email"] = user.email
# Build display name from email (User model doesn't have first/last name)
token["user_name"] = user.email.split("@")[0] if user.email else ""
token["user_profile_pic"] = user.profile_pic or ""
# Add org context to the token payload
if org:
org_id = str(org.id) if hasattr(org, "id") else str(org)
token["org_id"] = org_id
# Add org_name for display (avoids /api/auth/profile call)
if hasattr(org, "name"):
token["org_name"] = org.name
# Add org settings for currency/locale
if hasattr(org, "default_currency"):
token["org_settings"] = {
"default_currency": org.default_currency or "USD",
"currency_symbol": CURRENCY_SYMBOLS.get(
org.default_currency or "USD", "$"
),
"default_country": org.default_country,
# The org's day, for a form's "today" default. Display
# only: the API dates what it owns itself.
"timezone": org.timezone or "UTC",
}
# Add role if profile provided (avoids /api/auth/profile call)
if profile:
token["role"] = profile.role
token["is_organization_admin"] = is_org_admin(profile)
return token
class OrganizationSerializer(serializers.ModelSerializer):
"""Public org representation, nested into most CRM records.
`api_key` is deliberately excluded: it authenticates its bearer as the
org's first ADMIN profile (see `common.external_auth.APIKeyAuthentication`),
so including it here would hand every org member -- including role="USER"
-- a path to admin. Admins read and rotate it via `OrgApiKeyView`.
"""
class Meta:
model = Org
fields = ("id", "name")
class OrgSettingsSerializer(serializers.ModelSerializer):
"""The org's own settings: company profile, locale, and case-handling switches.
This is the ONLY writable representation of the org an admin edits through the
settings page (`OrgSettingsView`). The field list is an allow-list on purpose:
- `api_key` is absent. It authenticates its bearer as the org's first ADMIN
profile (see `OrganizationSerializer`), so it is never editable or readable
through a settings form; admins rotate it via `OrgApiKeyView`.
- `is_active` (the org-level kill switch) and `id` are not writable here either
-- an org must not be able to deactivate itself, or change its own id, from a
profile-edit form.
`csat_enabled` and `auto_close_children_on_parent_close` are real `Org` columns
that previously had NO write path through any serializer; this is where they are
edited. `member_count` and `created_at` are read-only header context.
`vertical` and `terminology` are read-only here too: they are written exclusively
by the vertical-pack applier (`common/packs/applier.py`), never by a client. A
writable path would let any admin PATCH `terminology` to arbitrary tenant-supplied
text (still safe to render -- see `$lib/terminology.js` on the frontend -- but not
something this endpoint is meant to let anyone set by hand) or overwrite `vertical`,
which is meant to record which pack was actually applied.
The five numbering fields set how new invoice and estimate numbers look
(#802). Their format rules are validators on the `Org` columns; `validate`
adds the one rule that spans two of them.
"""
currency_symbol = serializers.SerializerMethodField()
logo_url = serializers.SerializerMethodField()
member_count = serializers.SerializerMethodField()
class Meta:
model = Org
fields = [
"id",
"name",
# Company profile
"company_name",
"logo",
"logo_url",
"address_line",
"city",
"state",
"postcode",
"country",
"phone",
"email",
"website",
"tax_id",
# Locale settings
"default_currency",
"default_country",
"currency_symbol",
"timezone",
# Case-handling behaviour (org-wide switches). Editable only here.
"csat_enabled",
"auto_close_children_on_parent_close",
# Invoice and estimate numbering. See `Org.NUMBER_DATE_PARTS`.
"invoice_number_prefix",
"estimate_number_prefix",
"number_date_part",
"number_padding",
"invoice_number_start",
# Vertical pack. Read-only -- see the class docstring.
"vertical",
"terminology",
# Read-only context for the settings page header.
"member_count",
"created_at",
]
read_only_fields = [
"id",
"currency_symbol",
"logo_url",
"member_count",
"created_at",
"vertical",
"terminology",
]
@extend_schema_field(str)
def get_currency_symbol(self, obj):
return CURRENCY_SYMBOLS.get(obj.default_currency or "USD", "$")
def validate(self, attrs):
# A dated counter starts again each period, so a starting number would
# start it there every time. Checked against the stored value of
# whichever half this PATCH leaves out, so adding a date to a sequence
# that has a start has to reset it. The model's check constraint is
# the backstop for any other write path.
date_part = attrs.get("number_date_part", self.instance.number_date_part)
start = attrs.get("invoice_number_start", self.instance.invoice_number_start)
if date_part != "NONE" and start != 1:
raise serializers.ValidationError(
{
"invoice_number_start": [
"A starting number only applies to numbers without a "
"date. A dated number's counter restarts at 1 each period."
]
}
)
return attrs
def validate_logo(self, logo):
return validate_upload_size(logo)
@extend_schema_field(str)
def get_logo_url(self, obj):
if obj.logo:
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.logo.url)
return obj.logo.url
return None
@extend_schema_field(int)
def get_member_count(self, obj):
# Active members in this org, for the settings header. Naturally
# org-scoped: obj is always request.profile.org.
return obj.profiles.filter(is_active=True).count()
HELP_CENTER_SLUG_TAKEN = "That address is already taken. Choose another."
class HelpCenterSettingsSerializer(serializers.ModelSerializer):
"""The two help center columns, and the only write path to them.
Kept apart from `OrgSettingsSerializer` because this switch publishes org
content to anonymous visitors and search engines, so it is reviewed and
tested on its own rather than riding along with the company address.
The slug is normalised before it is validated: surrounding whitespace is
dropped and it is lowercased, and a blank one is stored as NULL so that
orgs without an address never collide with each other.
"""
# Declared rather than generated so the format message comes from
# `validate_help_center_slug` and not from a generic max_length check.
help_center_slug = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
public_url = serializers.SerializerMethodField()
class Meta:
model = Org
fields = ("help_center_enabled", "help_center_slug", "public_url")
read_only_fields = ("public_url",)
@extend_schema_field(str)
def get_public_url(self, obj):
if not obj.help_center_slug:
return None
return frontend_url(f"/help-center/{obj.help_center_slug}")
def validate_help_center_slug(self, value):
value = (value or "").strip().lower()
if not value:
return None
try:
validate_help_center_slug(value)
except DjangoValidationError as exc:
raise serializers.ValidationError(exc.messages) from exc
taken = Org.objects.filter(help_center_slug__iexact=value)
if self.instance is not None:
taken = taken.exclude(pk=self.instance.pk)
if taken.exists():
raise serializers.ValidationError(HELP_CENTER_SLUG_TAKEN)
return value
def validate(self, attrs):
instance = self.instance
enabled = attrs.get(
"help_center_enabled", instance.help_center_enabled if instance else False
)
slug = (
attrs["help_center_slug"]
if "help_center_slug" in attrs
else (instance.help_center_slug if instance else None)
)
if enabled and not slug:
raise serializers.ValidationError(
{
"help_center_slug": (
"Choose an address before turning the help center on."
)
}
)
return attrs
class TagsSerializer(serializers.ModelSerializer):
class Meta:
model = Tags
fields = (
"id",
"name",
"slug",
"color",
"description",
"is_active",
"created_at",
)
read_only_fields = ("id", "slug", "created_at")
class SocialLoginSerializer(serializers.Serializer):
token = serializers.CharField()
class CommentUserSerializer(serializers.ModelSerializer):
"""Simplified user serializer for comments"""
user_details = serializers.SerializerMethodField()
class Meta:
model = Profile
fields = ("id", "user_details")
def get_user_details(self, obj):
if obj.user:
return {"email": obj.user.email, "profile_pic": obj.user.profile_pic}
return None
class CommentContactSerializer(serializers.ModelSerializer):
"""The customer who wrote a portal reply.
Name and email only. An agent reading the thread needs to know which person
at the account is talking to them; the rest of the contact record is a click
away and does not belong inline in a comment.
"""
name = serializers.SerializerMethodField()
class Meta:
model = Contact
fields = ("id", "name", "email")
def get_name(self, obj):
return f"{obj.first_name or ''} {obj.last_name or ''}".strip()
class CommentSerializer(serializers.ModelSerializer):
"""Serializer for Comment model using ContentType"""
content_type = serializers.SlugRelatedField(slug_field="model", read_only=True)
commented_by = CommentUserSerializer(read_only=True)
# A portal reply has no Profile, so `commented_by` is null on it. Without
# this field the agents' clients had nothing to render and fell back to
# "Unknown" on mobile, which is a worse answer than the one before the
# portal existed. The two author fields are mutually exclusive by check
# constraint (`comment_single_author`), so exactly one is ever populated.
commented_by_contact = CommentContactSerializer(read_only=True)
class Meta:
model = Comment
fields = (
"id",
"comment",
"commented_on",
"commented_by",
"commented_by_contact",
"content_type",
"object_id",
"org",
"is_internal",
)
# Which record a comment hangs off, and which org owns it, are the
# server's to decide. Left writable, the edit endpoints (which are
# `partial=True` and gated only on "you wrote it") let an author move
# their own comment onto any record in the org by id, including cases
# `cases.access` would refuse them, or restamp it into another org.
# `Comment.clean()` compares the new org against the new target's org,
# so a matching cross-org pair passed that check and saved; only the
# RLS policy stopped it, and RLS is the safety net, not the contract.
#
# Making them read-only also repairs the edit endpoints. This
# serializer is used non-partial by four PUT handlers, where
# `object_id` and `org` were required fields no honest client sends,
# so every ordinary "fix my typo" edit failed validation.
#
# Callers that legitimately set these do it through `save()`, where the
# value comes from the server rather than the request body.
read_only_fields = (
"commented_by",
"commented_by_contact",
"content_type",
"object_id",
"org",
)
class CommentCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating comments with ContentType"""
content_type = serializers.CharField(write_only=True)
object_id = serializers.UUIDField(write_only=True)
class Meta:
model = Comment
fields = (
"comment",
"content_type",
"object_id",
)
def create(self, validated_data):
from django.contrib.contenttypes.models import ContentType
content_type_str = validated_data.pop("content_type")
try:
content_type = ContentType.objects.get(model=content_type_str.lower())
except ContentType.DoesNotExist as exc:
raise serializers.ValidationError(
f"Invalid content type: {content_type_str}"
) from exc
validated_data["content_type"] = content_type
return super().create(validated_data)
class NotificationSerializer(serializers.ModelSerializer):
"""In-app notification. List/feed shape returned by GET and SSE."""
actor = CommentUserSerializer(read_only=True)
class Meta:
model = Notification
fields = (
"id",
"verb",
"actor",
"entity_type",
"entity_id",
"entity_name",
"data",
"link",
"read_at",
"created_at",
)
read_only_fields = fields
class LeadCommentSerializer(serializers.ModelSerializer):
"""Comment serializer with user details for display"""
commented_by = CommentUserSerializer(read_only=True)
class Meta:
model = Comment
fields = (
"id",
"comment",
"commented_on",
"commented_by",
)
class CustomFieldDefinitionSerializer(serializers.ModelSerializer):
"""Per-org custom-field definition. key/target_model/field_type are immutable
after creation. Admins must create a new definition to change shape."""
target_model = serializers.ChoiceField(
choices=sorted((e.custom_field_target, e.label_plural) for e in BUILT_IN)
)
class Meta:
model = CustomFieldDefinition
fields = (
"id",
"target_model",
"key",
"label",
"field_type",
"options",
"is_required",
"is_filterable",
"display_order",
"is_active",
"created_at",
"updated_at",
)
read_only_fields = ("id", "created_at", "updated_at")
def validate_target_model(self, value):
org = self.context.get("org")
module = object_module(target=value)
if org is not None and module and not module_is_available(org, module):
raise serializers.ValidationError(module_off_message(org, module))
return value
def validate_key(self, value):
if not value:
raise serializers.ValidationError("key is required")
if not re.fullmatch(r"[a-z][a-z0-9_]*", value):
raise serializers.ValidationError(
"key must be a lowercase slug starting with a letter (a-z, 0-9, _)"
)
return value
def validate(self, attrs):
if self.instance is not None:
for frozen in ("key", "target_model", "field_type"):
if frozen in attrs and getattr(self.instance, frozen) != attrs[frozen]:
raise serializers.ValidationError(
{frozen: f"{frozen} cannot be changed after creation"}
)
field_type = attrs.get("field_type", self.instance.field_type)
else:
field_type = attrs.get("field_type")
options = attrs.get("options", getattr(self.instance, "options", None))
validate_definition_options(field_type, options)
org = self.context.get("org")
target_model = attrs.get(
"target_model", getattr(self.instance, "target_model", None)
)
key = attrs.get("key", getattr(self.instance, "key", None))
if org is not None and target_model and key:
qs = CustomFieldDefinition.objects.filter(
org=org, target_model=target_model, key=key
)
if self.instance is not None:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise serializers.ValidationError(
{"key": f"a {target_model} field with key {key!r} already exists"}
)
return attrs
class ActivitySerializer(serializers.ModelSerializer):
"""Activity timeline row, used by audit-log feeds (Cases first)."""
user = CommentUserSerializer(read_only=True)
# The label from `Activity.ACTION_CHOICES` ("Time Logged"), so no client
# has to keep its own copy of a verb list that lives on the model. Both
# the web and the Flutter timeline already read this key and were falling
# back to the raw enum, which is why they printed "TIME_LOGGED".
action_display = serializers.CharField(source="get_action_display", read_only=True)
class Meta:
model = Activity
fields = (
"id",
"action",
"action_display",
"user",
"entity_type",
"entity_id",
"entity_name",
"description",
"metadata",
"created_at",
)
class OrgProfileCreateSerializer(serializers.ModelSerializer):
"""
It is for creating organization
"""
name = serializers.CharField(max_length=255)
class Meta:
model = Org
# "id" is Org's UUIDField(editable=False) primary key -- DRF makes it
# read-only automatically because of `editable=False`, so listing it
# here only exposes the created org's id in the response; it does not
# open a new mass-assignment surface (a client-supplied "id" in the
# request body is still ignored, same as before this field existed).
# Callers (org/new/+page.server.js) need this id to switch the
# session into the org they just created and to apply a vertical
# pack to it -- see the C1 fix in the vertical-packs final review.
# `timezone` is optional on purpose. Mobile builds ship from an app
# store and update whenever the user gets round to it, so requiring a
# field an installed build does not send would break org creation for
# them with no server-side fix available. Omitted means UTC, which the
# admin can change in settings afterwards.
fields = ["id", "name", "timezone"]
extra_kwargs = {
"name": {"required": True},
"timezone": {"required": False},
}
def validate_name(self, name):
if bool(re.search(r"[~\!@#\$%\^&\*\(\)\+{}\":;'/\[\]]", name)):
raise serializers.ValidationError(
"organization name should not contain any special characters"
)
if Org.objects.filter(name=name).exists():
raise serializers.ValidationError(
"Organization already exists with this name"
)
return name
class ShowOrganizationListSerializer(serializers.ModelSerializer):
"""
we are using it for show orjanization list
"""
org = OrganizationSerializer()
# Computed, not the column: the column mirrors `role` only, and this is
# the fact the clients gate admin UI on. Read-only by construction.
is_organization_admin = serializers.SerializerMethodField()
@extend_schema_field(bool)
def get_is_organization_admin(self, obj):
return is_org_admin(obj)
class Meta:
model = Profile
fields = (
"role",
"alternate_phone",
"has_sales_access",
"has_marketing_access",
"is_organization_admin",
"org",
)
class BillingAddressSerializer(serializers.ModelSerializer):
class Meta:
model = Address
fields = ("address_line", "street", "city", "state", "postcode", "country")
def __init__(self, *args, **kwargs):
account_view = kwargs.pop("account", False)
super().__init__(*args, **kwargs)
if account_view:
self.fields["address_line"].required = True
self.fields["street"].required = True
self.fields["city"].required = True
self.fields["state"].required = True
self.fields["postcode"].required = True
self.fields["country"].required = True
class CreateUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
"email",
"profile_pic",
)
def __init__(self, *args, **kwargs):
self.org = kwargs.pop("org", None)
# Whether the caller is editing their own account. Defaults to False so
# a caller that forgets to pass it fails closed: the account fields
# below are then refused rather than written.
self._editing_self = kwargs.pop("editing_self", False)
super().__init__(*args, **kwargs)
self.fields["email"].required = True
# Membership is per-org: one account can belong to several orgs, so the
# model-level unique check would wrongly reject inviting someone who
# already signed up elsewhere. validate_email enforces the real rule --
# one profile per email per org -- and the view reuses the existing
# User instead of creating a duplicate.
self.fields["email"].validators = [
validator
for validator in self.fields["email"].validators
if not isinstance(validator, UniqueValidator)
]
def validate_email(self, email):
if self.instance:
# The login email is read-only through this API, for an admin
# editing someone else and for the user themselves (owner decision,
# 1.11.0). `User.email` is global: an org admin who could rename a
# member's email could then sign in as that person in every org
# they belong to, and as a platform superadmin if they are one.
# Echoing the stored address back (any case) is not a change, so
# forms that resend the whole record keep working; the stored
# spelling is kept rather than the echoed one.
if self.instance.email.lower() == email.lower():
return self.instance.email
raise serializers.ValidationError("Email cannot be changed here.")
# Creating a membership: an account owned elsewhere is reused by the
# view, so only same-org duplicates are rejected here.
if not Profile.objects.filter(user__email__iexact=email, org=self.org).exists():
return email.lower()
raise serializers.ValidationError("Given Email id already exists")
def validate_profile_pic(self, value):
"""Only the account's owner may change it once the account exists.
`User` is one row shared by every org the person belongs to, so an org
admin editing a member here would be rewriting that person's account
for their other orgs too (owner decision, 1.11.0). An admin resending
the stored value, as a whole-record form does, is not a change.
"""
if self.instance is None or self._editing_self:
return value
if (value or "") == (self.instance.profile_pic or ""):
return self.instance.profile_pic
raise serializers.ValidationError(
"Only the account's owner can change their picture."
)
class CreateProfileSerializer(serializers.ModelSerializer):
class Meta:
model = Profile
# `is_organization_admin` is deliberately absent. It is derived from
# `role` in `Profile.save`, and accepting it here was a second, hidden
# way to grant admin: no frontend surface shows or sets the flag, so a
# profile carrying it read as a plain user everywhere a human looked
# while `is_org_admin` answered True. One fact, one input.
fields = (
"role",
"phone",
"alternate_phone",
"has_sales_access",
"has_marketing_access",
)
# The fields that grant access rather than describe a person. Only an admin
# acting on someone other than themselves may set these; see the caller
# notes in common/views/user_views.py.
PRIVILEGED_FIELDS = (
"role",
"has_sales_access",
"has_marketing_access",
)
def __init__(self, *args, **kwargs):
# `can_grant_privileges` gates the fields above. It defaults to False so
# a caller that forgets to pass it fails closed: the worst outcome of a
# missing flag is that a privileged field is ignored, never that an
# unprivileged caller gets to set one. A plain member editing their own
# profile could otherwise PATCH themselves to role="ADMIN". A live
# privilege escalation this closes by making these fields read_only
# (so they are silently ignored) when the caller may not grant them.
can_grant = kwargs.pop("can_grant_privileges", False)
super().__init__(*args, **kwargs)
self._can_grant_privileges = can_grant
self.fields["alternate_phone"].required = False
self.fields["phone"].required = False
if can_grant:
self.fields["role"].required = True
else:
for name in self.PRIVILEGED_FIELDS:
# read_only and required are mutually exclusive in DRF, so this
# also drops role's requirement for the self-edit path.
self.fields[name].read_only = True
def validate(self, attrs):
"""Say no out loud when a privileged field was actually being changed.
Making the fields read_only above is what makes this safe, and it is
the half that must never be removed. It is not the half a caller can
see: DRF drops a read_only field without a word, so `PATCH {"role":
"USER"}` on your own profile answered ``200 User Updated Successfully``
and changed nothing. Both clients then told the user the role had
changed, which is a worse failure than a refusal.
Only a *different* value is refused. A client echoing the whole profile
back, including the role it already has, is asking for no change and
gets none, the same as before.
"""
if self._can_grant_privileges or self.instance is None:
return attrs
submitted = self.initial_data if isinstance(self.initial_data, dict) else {}
errors = {
name: ["Only an admin can change this, and never on themselves."]
for name in self.PRIVILEGED_FIELDS
if name in submitted
and not self._unchanged(submitted[name], getattr(self.instance, name))
}
if errors:
raise serializers.ValidationError(errors)
return attrs
@staticmethod
def _unchanged(submitted, current):
"""Is the submitted value the one already stored?
Compared as text, and as a boolean when the stored value is one, since
a form sends ``"true"`` where the column holds ``True``.
"""
if isinstance(current, bool):
accepted = (
serializers.BooleanField.TRUE_VALUES
if current
else serializers.BooleanField.FALSE_VALUES
)
# Compared one by one rather than with `in`, because a caller can
# send a list or a dict here and those are not hashable.
return any(submitted == value for value in accepted)
return str(submitted) == str(current)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "email", "name", "profile_pic"]
class ProfileSerializer(serializers.ModelSerializer):
# address = BillingAddressSerializer()
user_details = serializers.SerializerMethodField()
@extend_schema_field(dict)
def get_user_details(self, obj):
return obj.user_details
class Meta:
model = Profile
fields = (
"id",
"user_details",
"role",
"address",
"has_marketing_access",
"has_sales_access",
"phone",
"date_of_joining",
"is_active",
"created_at",
)
class ProfileSelfUpdateSerializer(serializers.Serializer):
"""The two fields a user may change on their OWN profile via PATCH /api/profile/.
Deliberately just ``phone`` (on Profile) and ``name`` (on User). Role, org
and the ``has_*_access`` flags are NOT here:
those decide permissions and are an admin's to set. Leaving them out is what
keeps this self-edit endpoint from becoming the same self-serve privilege
escalation ``CreateProfileSerializer`` once was. A field the serializer does
not name cannot be written, no matter what the request body contains.
``phone`` is validated (not written raw as the view used to), so a bad value
is a clean 400 instead of an unvalidated string in the column. A blank phone
is allowed and clears it, ``allow_blank`` short-circuits before the regex,
which requires 7-25 characters.
"""
name = serializers.CharField(required=False, allow_blank=True, max_length=255)
phone = serializers.CharField(
required=False,
allow_blank=True,
max_length=20,
validators=[flexible_phone_validator],
)
class AttachmentsSerializer(serializers.ModelSerializer):
"""Serializer for Attachments model using ContentType"""
file_path = serializers.SerializerMethodField()
content_type = serializers.SlugRelatedField(slug_field="model", read_only=True)
@extend_schema_field(str)
def get_file_path(self, obj):
if obj.attachment:
return obj.attachment.url
return None
class Meta:
model = Attachments
fields = [
"id",
"created_by",
"file_name",
"created_at",
"file_path",
"content_type",
"object_id",
"org",
]
class AttachmentsCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating attachments with ContentType"""
content_type = serializers.CharField(write_only=True)
object_id = serializers.UUIDField(write_only=True)
class Meta:
model = Attachments
fields = (
"file_name",
"attachment",
"content_type",
"object_id",
)
def create(self, validated_data):
from django.contrib.contenttypes.models import ContentType
content_type_str = validated_data.pop("content_type")
try:
content_type = ContentType.objects.get(model=content_type_str.lower())
except ContentType.DoesNotExist as exc:
raise serializers.ValidationError(
f"Invalid content type: {content_type_str}"
) from exc
validated_data["content_type"] = content_type
return super().create(validated_data)
class DocumentSerializer(serializers.ModelSerializer):
shared_to = ProfileSerializer(read_only=True, many=True)
teams = serializers.SerializerMethodField()
created_by = UserSerializer()
org = OrganizationSerializer()
size_bytes = serializers.SerializerMethodField()
@extend_schema_field(list)
def get_teams(self, obj):
# `member_count` is what a reader needs to understand a team share;
# "Support (3)" says how many people the pick actually reaches, which a
# bare team name does not. Naming the team rather than flattening it to
# its members keeps the share meaning what was chosen as people join and
# leave. Only the (unpaginated, small) documents page consumes this.
return [
{"id": str(team.id), "name": team.name, "member_count": team.users.count()}
for team in obj.teams.all()
]
@extend_schema_field(int)
def get_size_bytes(self, obj):
# The file's size on disk, so the list can show "284 kB" instead of a
# bare filename. Guarded because `.size` reaches the storage backend: a
# row whose file is missing (a stale path, a not-yet-synced media dir)
# must degrade to an unknown size, never 500 the whole list.
try:
return obj.document_file.size
except (ValueError, OSError):
return None
class Meta:
model = Document
fields = [
"id",
"title",
"document_file",
"status",
"shared_to",
"teams",
"size_bytes",
"created_at",
"created_by",
"org",
]
class DocumentCreateSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
request_obj = kwargs.pop("request_obj", None)
super().__init__(*args, **kwargs)
self.fields["title"].required = True
self.org = request_obj.profile.org
def validate_title(self, title):
if self.instance:
if (
Document.objects.filter(title__iexact=title, org=self.org)
.exclude(id=self.instance.id)
.exists()
):
raise serializers.ValidationError(
"Document with this Title already exists"
)
else:
if Document.objects.filter(title__iexact=title, org=self.org).exists():
raise serializers.ValidationError(
"Document with this Title already exists"
)
return title
def validate_document_file(self, file):
return validate_upload_size(file)
class Meta:
model = Document
fields = ["title", "document_file", "status", "org"]
read_only_fields = ["org"]
def find_urls(string):
# website_regex = "^((http|https)://)?([A-Za-z0-9.-]+\.[A-Za-z]{2,63})?$" # (http(s)://)google.com or google.com
# website_regex = "^https?://([A-Za-z0-9.-]+\.[A-Za-z]{2,63})?$" # (http(s)://)google.com
# http(s)://google.com
website_regex = r"^https?://[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$"
# http(s)://google.com:8000
website_regex_port = r"^https?://[A-Za-z0-9.-]+\.[A-Za-z]{2,63}:[0-9]{2,4}$"
url = re.findall(website_regex, string)
url_port = re.findall(website_regex_port, string)
if url and url[0] != "":
return url
return url_port
class APISettingsSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Meta:
model = APISettings
fields = ("title", "website")
def validate_website(self, website):
if website and not (
website.startswith("http://") or website.startswith("https://")
):
raise serializers.ValidationError("Please provide valid schema")
if not len(find_urls(website)) > 0:
raise serializers.ValidationError(
"Please provide a valid URL with schema and without trailing slash - Example: http://google.com"
)
return website
class APISettingsListSerializer(serializers.ModelSerializer):
created_by = UserSerializer()
lead_assigned_to = ProfileSerializer(read_only=True, many=True)
tags = serializers.SerializerMethodField()
org = OrganizationSerializer()
@extend_schema_field(list)
def get_tags(self, obj):
return obj.tags.all().values()
class Meta:
model = APISettings
fields = [
"id",
"title",
"apikey",
"website",
"created_at",
"created_by",
"lead_assigned_to",
"tags",
"org",
]
def to_representation(self, instance):
"""`apikey` is a live credential, so only an admin reads it back.
It is the sole thing `CreateLeadFromSite` authenticates on (see
`leads/views/lead_interactions.py`, which matches on the key alone and
has its website check commented out). Anyone holding it can post Lead
and Contact rows into the org anonymously, attributed to whoever
created the setting. Handing that to every USER-role member of the org
made a shared credential out of an admin one.
Unlike the `webhook_secret` on `InboundMailboxSerializer`, this cannot
be `write_only`: the key exists to be pasted into a website form, so
the admin who manages the integration has to read it back. Admin-only
on read is the same treatment `topic_arn` gets there.
Popped when the view passes no request in context, since a serializer
with no caller cannot prove the caller is an admin. Both call sites in
`common/views/settings_views.py` pass it.
"""
data = super().to_representation(instance)
request = self.context.get("request")
if not is_org_admin(getattr(request, "profile", None)):
data.pop("apikey", None)
return data
class APISettingsSwaggerSerializer(serializers.Serializer):
"""The request body `common/views/settings_views.py` actually reads.
A ModelSerializer here documented both relations as lists of ids, while the
views have always taken `tags` as names. Written out by hand so the schema
says what the endpoint does. On PATCH every field is optional, and an
absent `tags` or `lead_assigned_to` is left unchanged.
"""
title = serializers.CharField()
website = serializers.URLField(max_length=255)
tags = serializers.ListField(
child=serializers.CharField(),
required=False,
help_text=(
"Tag NAMES, not ids. Each is matched by slug within the caller's "
"org and created there if the org has none by that name."
),
)
lead_assigned_to = serializers.ListField(
child=serializers.UUIDField(),
required=False,
help_text=(
"Profile ids. Each must be an active member of the caller's org, "
"or the request is refused with a 400."
),
)
class DocumentCreateSwaggerSerializer(serializers.ModelSerializer):
class Meta:
model = Document
fields = [
"title",
"document_file",
"teams",
"shared_to",
]
class DocumentEditSwaggerSerializer(serializers.ModelSerializer):
class Meta:
model = Document
fields = ["title", "document_file", "teams", "shared_to", "status"]
class UserCreateSwaggerSerializer(serializers.Serializer):
"""
It is swagger for creating or updating user
"""
ROLE_CHOICES = ["ADMIN", "USER"]
email = serializers.CharField(max_length=1000, required=True)
role = serializers.ChoiceField(choices=ROLE_CHOICES, required=True)
phone = serializers.CharField(
max_length=12, required=False, allow_blank=True, allow_null=True
)
alternate_phone = serializers.CharField(
max_length=12, required=False, allow_blank=True, allow_null=True
)
address_line = serializers.CharField(
max_length=10000, required=False, allow_blank=True
)
street = serializers.CharField(max_length=1000, required=False, allow_blank=True)
city = serializers.CharField(max_length=1000, required=False, allow_blank=True)
state = serializers.CharField(max_length=1000, required=False, allow_blank=True)
pincode = serializers.CharField(max_length=1000, required=False, allow_blank=True)
country = serializers.CharField(max_length=1000, required=False, allow_blank=True)
class UserUpdateStatusSwaggerSerializer(serializers.Serializer):
STATUS_CHOICES = ["Active", "Inactive"]
status = serializers.ChoiceField(choices=STATUS_CHOICES, required=True)
# JWT Authentication Serializers for SvelteKit Integration
class MagicLinkRequestSerializer(serializers.Serializer):
"""Serializer for requesting a magic link or OTP code."""
email = serializers.EmailField(required=True)
delivery = serializers.ChoiceField(
choices=("link", "code"), required=False, default="link"
)
def validate_email(self, value):
domain = value.rsplit("@", 1)[-1].lower()
if domain in disposable_domains:
raise serializers.ValidationError(
"Disposable email addresses are not allowed."
)
return value
class MagicLinkVerifySerializer(serializers.Serializer):
"""Serializer for verifying a magic link token."""
token = serializers.CharField(required=True, max_length=64)
class MagicLinkVerifyCodeSerializer(serializers.Serializer):
"""Serializer for verifying an OTP code (mobile flow)."""
email = serializers.EmailField(required=True)
code = serializers.RegexField(r"^\d{6}$", required=True, max_length=6)
class UserDetailSerializer(serializers.ModelSerializer):
"""Detailed user serializer with profile and organizations"""
organizations = serializers.SerializerMethodField()
class Meta:
model = User
fields = ["id", "email", "profile_pic", "is_active", "organizations"]
@extend_schema_field(list)
def get_organizations(self, obj):
"""Get all organizations the user belongs to"""
profiles = Profile.objects.filter(user=obj, is_active=True).select_related(
"org", "user"
)
return [
{
"id": str(profile.org.id),
"name": profile.org.name,
"role": profile.role,
"is_organization_admin": is_org_admin(profile),
"has_sales_access": profile.has_sales_access,
"has_marketing_access": profile.has_marketing_access,
}
for profile in profiles
]
class ProfileDetailSerializer(serializers.ModelSerializer):
"""The caller's OWN profile, in whichever org their token names.
`teams` and `last_login` are here because the mobile profile screen reads
this endpoint while the web one reads `ProfileView`, which had both, so the
two screens described the same person differently. Widening this serializer
rather than pointing mobile at the other endpoint keeps each client on the
endpoint it already uses.
Safe to widen because this is only ever the requester's own row:
`ProfileDetailView` serializes `request.profile` and takes no id. Neither
field is on the shared `ProfileSerializer`, which is nested into lists of
OTHER people's profiles, and adding them there would publish when every
colleague last signed in.
"""
user = UserSerializer(read_only=True)
org = OrganizationSerializer(read_only=True)
teams = serializers.SerializerMethodField()
last_login = serializers.DateTimeField(source="user.last_login", read_only=True)
# `is_org_admin`, not the column; see `ShowOrganizationListSerializer`.
is_organization_admin = serializers.SerializerMethodField()
@extend_schema_field(serializers.ListField(child=serializers.CharField()))
def get_teams(self, obj):
return list(obj.user_teams.values_list("name", flat=True))
@extend_schema_field(bool)
def get_is_organization_admin(self, obj):
return is_org_admin(obj)
class Meta:
model = Profile
fields = [
"id",
"user",
"org",
"role",
"is_organization_admin",
"has_sales_access",
"has_marketing_access",
"phone",
"date_of_joining",
"is_active",
"teams",
"last_login",
]
# Activity Serializers for Dashboard Recent Activities
class ActivityUserSerializer(serializers.Serializer):
"""Simplified user info for activity display"""
id = serializers.UUIDField(source="user.id")
email = serializers.EmailField(source="user.email")
name = serializers.SerializerMethodField()
profile_pic = serializers.CharField(source="user.profile_pic", allow_null=True)
@extend_schema_field(str)
def get_name(self, obj):
"""Get display name from email"""
return obj.user.email.split("@")[0]
class DashboardActivitySerializer(serializers.ModelSerializer):
"""Recent-activity row for the dashboard feed.
Renamed 2026-08-05. It was a second class also called
`ActivitySerializer`, 580 lines below the first, so it silently shadowed
it and **both** consumers got this shape. `cases/views.py` imports the
other one for the ticket activity timeline, whose frontend component reads
`metadata` (five places) and `created_at`; neither is in this list, so the
timeline rendered no metadata and an invalid date. The dashboard reads
`timestamp` and `humanized_time`, which is why only one of the two broke
visibly.
"""
user = ActivityUserSerializer(read_only=True)
action_display = serializers.CharField(source="get_action_display", read_only=True)
timestamp = serializers.DateTimeField(source="created_at", read_only=True)
humanized_time = serializers.CharField(source="created_on_arrow", read_only=True)
class Meta:
model = Activity
fields = [
"id",
"user",
"action",
"action_display",
"entity_type",
"entity_id",
"entity_name",
"description",
"timestamp",
"humanized_time",
]
class TeamsSerializer(serializers.ModelSerializer):
users = ProfileSerializer(read_only=True, many=True)
created_by = UserSerializer()
class Meta:
model = Teams
fields = (
"id",
"name",
"description",
"users",
"created_at",
"created_by",
)
class TeamCreateSerializer(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["name"].required = True
self.fields["description"].required = False
def validate_name(self, name):
if self.instance:
if (
Teams.objects.filter(name__iexact=name, org=self.org)
.exclude(id=self.instance.id)
.exists()
):
raise serializers.ValidationError("Team already exists with this name")
else:
if Teams.objects.filter(name__iexact=name, org=self.org).exists():
raise serializers.ValidationError("Team already exists with this name")
return name
class Meta:
model = Teams
fields = (
"name",
"description",
"created_at",
"created_by",
"org",
)
read_only_fields = ("created_at", "created_by", "org")
class TeamswaggerCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Teams
fields = (
"name",
"description",
"users",
)
class PersonalAccessTokenListSerializer(serializers.ModelSerializer):
class Meta:
model = PersonalAccessToken
fields = (
"id",
"name",
"token_prefix",
"scopes",
"expires_at",
"last_used_at",
"created_at",
"revoked_at",
)
read_only_fields = fields
class PersonalAccessTokenCreateSerializer(serializers.ModelSerializer):
class Meta:
model = PersonalAccessToken
fields = ("name", "scopes", "expires_at")
def validate_name(self, value):
value = (value or "").strip()
if not value:
raise serializers.ValidationError("Name is required.")
if len(value) > 255:
raise serializers.ValidationError("Name too long (max 255).")
return value
def validate_scopes(self, value):
"""Reject scopes that mean nothing, and return them canonicalised.
This used to accept any list of up to 32 arbitrary strings, from a time
when nothing read the field. Now that `common.scopes` enforces it, an
unvalidated scope is worse than none: a caller who misspells a resource
believes they restricted the token, and gets one that matches nothing at
all. Say so at creation instead.
An empty list stays valid and still means unrestricted. See the
`common.scopes` module docstring for why.
"""
from common.scopes import normalize_scope
if value in (None, ""):
return []
if not isinstance(value, list) or not all(isinstance(s, str) for s in value):
raise serializers.ValidationError("scopes must be a list of strings.")
if len(value) > 32:
raise serializers.ValidationError("Too many scopes (max 32).")
seen = []
for raw in value:
try:
scope = normalize_scope(raw)
except ValueError as exc:
raise serializers.ValidationError(str(exc)) from exc
if scope not in seen:
seen.append(scope)
return seen
def validate_expires_at(self, value):
from django.utils import timezone
if value is not None and value <= timezone.now():
raise serializers.ValidationError("expires_at must be in the future.")
return value