Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions dojo/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
Finding,
Finding_Group,
Finding_Template,
FindingBookmark,
General_Survey,
Global_Role,
Language_Type,
Expand Down Expand Up @@ -3243,3 +3244,185 @@ def create(self, validated_data):


from dojo.notifications.api.serializer import NotificationWebhooksSerializer # noqa: E402, F401 -- backward compat

# ---------------------------------------------------------------------------
# Mural fork: bookmarks + queue serializers for the watercolor FE.
# ---------------------------------------------------------------------------
_SEVERITY_RANK = {"Critical": 4, "High": 3, "Medium": 2, "Low": 1, "Info": 0}


class FindingCardAssigneeSerializer(serializers.Serializer):

"""Slim user payload used by the card grid for avatar chips."""

username = serializers.CharField()
display_name = serializers.CharField()
initials = serializers.CharField()
color_hex = serializers.CharField()


@extend_schema_field({"type": "string", "nullable": True})
class _DueInField(serializers.CharField):
pass


class FindingCardSerializer(serializers.ModelSerializer):

"""
Slim Finding payload sized for the FE card grid.

Returned by `/api/v2/bookmarks/` and `/api/v2/me/queue/`. Intentionally
a subset of `FindingSerializer` so payloads stay cheap at queue size.
"""

cve = serializers.CharField(allow_null=True, required=False)
cwe = serializers.IntegerField(allow_null=True, required=False)
cvss_score = serializers.SerializerMethodField()
age_days = serializers.SerializerMethodField()
scanner = serializers.SerializerMethodField()
sla_pct = serializers.SerializerMethodField()
due_in = serializers.SerializerMethodField()
tags = TagListSerializerField(required=False)
assignees = serializers.SerializerMethodField()
status = serializers.SerializerMethodField()
saved = serializers.SerializerMethodField()
created_at = serializers.DateTimeField(source="created", read_only=True)

class Meta:
model = Finding
fields = (
"id",
"title",
"severity",
"cve",
"cwe",
"cvss_score",
"created_at",
"age_days",
"scanner",
"sla_pct",
"due_in",
"tags",
"assignees",
"status",
"saved",
)

@extend_schema_field(serializers.FloatField(allow_null=True))
def get_cvss_score(self, obj):
# Prefer v4 if present, else v3. Rounded to 1 decimal per PRD.
score = obj.cvssv4_score if obj.cvssv4_score is not None else obj.cvssv3_score
if score is None:
return None
return round(float(score), 1)

@extend_schema_field(serializers.IntegerField(allow_null=True))
def get_age_days(self, obj):
# Finding has an `age` property when annotated; fall back to created.
try:
value = obj.age
if value is not None:
return int(value)
except (AttributeError, TypeError):
pass
if obj.date is None:
return None
return (timezone.now().date() - obj.date).days

@extend_schema_field(serializers.CharField(allow_null=True))
def get_scanner(self, obj):
try:
if obj.test_id and obj.test.test_type_id:
return obj.test.test_type.name
except Exception:
logger.debug("FindingCardSerializer.get_scanner: lookup failed", exc_info=True)
return None

@extend_schema_field(serializers.FloatField(allow_null=True))
def get_sla_pct(self, obj):
# 0.0 (just created) → 1.0+ (overdue). Null when no SLA configured.
deadline = obj.sla_expiration_date
start = obj.sla_start_date or obj.date
if deadline is None or start is None:
return None
total = (deadline - start).days
if total <= 0:
return 1.0
elapsed = (timezone.now().date() - start).days
return round(max(0.0, min(elapsed / total, 2.0)), 3)

@extend_schema_field(serializers.CharField(allow_null=True))
def get_due_in(self, obj):
days = None
try:
days = obj.sla_days_remaining()
except Exception:
days = None
if days is None:
return None
if days < 1:
# Less than a day: render hours for finer grain.
hours = max(int(days * 24), 0)
return f"{hours}h"
return f"{int(days)}d"

@extend_schema_field(FindingCardAssigneeSerializer(many=True))
def get_assignees(self, obj):
# Upstream DefectDojo models assignment as Finding.reviewers (M2M).
# The PRD called the field `assigned_to` (single FK); we surface the
# M2M here and let the FE iterate. See PR description for deviation.
out = []
try:
reviewers = obj.reviewers.all()
except Exception:
return out
# Deterministic 16-color palette so the FE can render avatar chips
# without a second round-trip. Palette is stable per username.
palette = [
"#7C3AED", "#2563EB", "#0EA5E9", "#0891B2",
"#059669", "#65A30D", "#CA8A04", "#EA580C",
"#DC2626", "#DB2777", "#9333EA", "#4F46E5",
"#0284C7", "#0D9488", "#16A34A", "#B45309",
]
for user in reviewers:
first = (user.first_name or "").strip()
last = (user.last_name or "").strip()
initials = ((first[:1] or "") + (last[:1] or "")) or (user.username[:2] or "?")
display = (f"{first} {last}".strip() or user.username)
color = palette[hash(user.username) % len(palette)]
out.append({
"username": user.username,
"display_name": display,
"initials": initials.upper(),
"color_hex": color,
})
return out

@extend_schema_field(serializers.CharField())
def get_status(self, obj):
return obj.status()

@extend_schema_field(serializers.BooleanField())
def get_saved(self, obj):
# /bookmarks/ always returns saved=True. /me/queue/ passes a set of
# bookmarked finding IDs in context so we can flag the union members
# in a single pass without N+1 queries.
bookmarked_ids = self.context.get("bookmarked_finding_ids")
if bookmarked_ids is None:
# Default to True for the bookmarks endpoint, which is the most
# common caller.
return True
return obj.id in bookmarked_ids


class FindingBookmarkSerializer(serializers.ModelSerializer):

"""Wire format for POST /api/v2/bookmarks/ responses."""

finding = serializers.PrimaryKeyRelatedField(queryset=Finding.objects.all())
user = serializers.PrimaryKeyRelatedField(read_only=True)

class Meta:
model = FindingBookmark
fields = ("id", "user", "finding", "created_at")
read_only_fields = ("id", "user", "created_at")
Loading
Loading